mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-02 10:39:13 +00:00
Merge pull request #44 from Adamsmith6300/select-github-repos
Select/edit GitHub repos when editing github connector
This commit is contained in:
commit
73623aa37e
14 changed files with 1099 additions and 168 deletions
|
@ -9,17 +9,18 @@ POST /search-source-connectors/{connector_id}/index - Index content from a conne
|
||||||
|
|
||||||
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR).
|
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR).
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, Body
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from app.db import get_async_session, User, SearchSourceConnector, SearchSourceConnectorType, SearchSpace, async_session_maker
|
from app.db import get_async_session, User, SearchSourceConnector, SearchSourceConnectorType, SearchSpace, async_session_maker
|
||||||
from app.schemas import SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead
|
from app.schemas import SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead, SearchSourceConnectorBase
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.check_ownership import check_ownership
|
||||||
from pydantic import ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
from app.tasks.connectors_indexing_tasks import index_slack_messages, index_notion_pages, index_github_repos, index_linear_issues
|
from app.tasks.connectors_indexing_tasks import index_slack_messages, index_notion_pages, index_github_repos, index_linear_issues
|
||||||
|
from app.connectors.github_connector import GitHubConnector
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -28,6 +29,34 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Use Pydantic's BaseModel here
|
||||||
|
class GitHubPATRequest(BaseModel):
|
||||||
|
github_pat: str = Field(..., description="GitHub Personal Access Token")
|
||||||
|
|
||||||
|
# --- New Endpoint to list GitHub Repositories ---
|
||||||
|
@router.post("/github/repositories/", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_github_repositories(
|
||||||
|
pat_request: GitHubPATRequest,
|
||||||
|
user: User = Depends(current_active_user) # Ensure the user is logged in
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fetches a list of repositories accessible by the provided GitHub PAT.
|
||||||
|
The PAT is used for this request only and is not stored.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Initialize GitHubConnector with the provided PAT
|
||||||
|
github_client = GitHubConnector(token=pat_request.github_pat)
|
||||||
|
# Fetch repositories
|
||||||
|
repositories = github_client.get_user_repositories()
|
||||||
|
return repositories
|
||||||
|
except ValueError as e:
|
||||||
|
# Handle invalid token error specifically
|
||||||
|
logger.error(f"GitHub PAT validation failed for user {user.id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid GitHub PAT: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch GitHub repositories for user {user.id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to fetch GitHub repositories.")
|
||||||
|
|
||||||
@router.post("/search-source-connectors/", response_model=SearchSourceConnectorRead)
|
@router.post("/search-source-connectors/", response_model=SearchSourceConnectorRead)
|
||||||
async def create_search_source_connector(
|
async def create_search_source_connector(
|
||||||
connector: SearchSourceConnectorCreate,
|
connector: SearchSourceConnectorCreate,
|
||||||
|
@ -76,6 +105,7 @@ async def create_search_source_connector(
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create search source connector: {str(e)}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
|
@ -130,54 +160,84 @@ async def update_search_source_connector(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update a search source connector.
|
Update a search source connector.
|
||||||
|
Handles partial updates, including merging changes into the 'config' field.
|
||||||
Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, etc.).
|
|
||||||
The config must contain the appropriate keys for the connector type.
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user)
|
db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user)
|
||||||
|
|
||||||
# If connector type is being changed, check if one of that type already exists
|
# Convert the sparse update data (only fields present in request) to a dict
|
||||||
if connector_update.connector_type != db_connector.connector_type:
|
update_data = connector_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Special handling for 'config' field
|
||||||
|
if "config" in update_data:
|
||||||
|
incoming_config = update_data["config"] # Config data from the request
|
||||||
|
existing_config = db_connector.config if db_connector.config else {} # Current config from DB
|
||||||
|
|
||||||
|
# Merge incoming config into existing config
|
||||||
|
# This preserves existing keys (like GITHUB_PAT) if they are not in the incoming data
|
||||||
|
merged_config = existing_config.copy()
|
||||||
|
merged_config.update(incoming_config)
|
||||||
|
|
||||||
|
# -- Validation after merging --
|
||||||
|
# Validate the *merged* config based on the connector type
|
||||||
|
# We need the connector type - use the one from the update if provided, else the existing one
|
||||||
|
current_connector_type = connector_update.connector_type if connector_update.connector_type is not None else db_connector.connector_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
# We can reuse the base validator by creating a temporary base model instance
|
||||||
|
# Note: This assumes 'name' and 'is_indexable' are not crucial for config validation itself
|
||||||
|
temp_data_for_validation = {
|
||||||
|
"name": db_connector.name, # Use existing name
|
||||||
|
"connector_type": current_connector_type,
|
||||||
|
"is_indexable": db_connector.is_indexable, # Use existing value
|
||||||
|
"last_indexed_at": db_connector.last_indexed_at, # Not used by validator
|
||||||
|
"config": merged_config
|
||||||
|
}
|
||||||
|
SearchSourceConnectorBase.model_validate(temp_data_for_validation)
|
||||||
|
except ValidationError as e:
|
||||||
|
# Raise specific validation error for the merged config
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Validation error for merged config: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If validation passes, update the main update_data dict with the merged config
|
||||||
|
update_data["config"] = merged_config
|
||||||
|
|
||||||
|
# Apply all updates (including the potentially merged config)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
# Prevent changing connector_type if it causes a duplicate (check moved here)
|
||||||
|
if key == "connector_type" and value != db_connector.connector_type:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SearchSourceConnector)
|
select(SearchSourceConnector)
|
||||||
.filter(
|
.filter(
|
||||||
SearchSourceConnector.user_id == user.id,
|
SearchSourceConnector.user_id == user.id,
|
||||||
SearchSourceConnector.connector_type == connector_update.connector_type,
|
SearchSourceConnector.connector_type == value,
|
||||||
SearchSourceConnector.id != connector_id
|
SearchSourceConnector.id != connector_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_connector = result.scalars().first()
|
existing_connector = result.scalars().first()
|
||||||
|
|
||||||
if existing_connector:
|
if existing_connector:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"A connector with type {connector_update.connector_type} already exists. Each user can have only one connector of each type."
|
detail=f"A connector with type {value} already exists. Each user can have only one connector of each type."
|
||||||
)
|
)
|
||||||
|
|
||||||
update_data = connector_update.model_dump(exclude_unset=True)
|
|
||||||
for key, value in update_data.items():
|
|
||||||
setattr(db_connector, key, value)
|
setattr(db_connector, key, value)
|
||||||
|
|
||||||
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(db_connector)
|
await session.refresh(db_connector)
|
||||||
return db_connector
|
return db_connector
|
||||||
except ValidationError as e:
|
|
||||||
await session.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Validation error: {str(e)}"
|
|
||||||
)
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
# This might occur if connector_type constraint is violated somehow after the check
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"Integrity error: A connector with this type already exists. {str(e)}"
|
detail=f"Database integrity error during update: {str(e)}"
|
||||||
)
|
)
|
||||||
except HTTPException:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
logger.error(f"Failed to update search source connector {connector_id}: {e}", exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Failed to update search source connector: {str(e)}"
|
detail=f"Failed to update search source connector: {str(e)}"
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
from .base import IDModel, TimestampModel
|
from .base import IDModel, TimestampModel
|
||||||
from app.db import SearchSourceConnectorType
|
from app.db import SearchSourceConnectorType
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
class SearchSourceConnectorBase(BaseModel):
|
class SearchSourceConnectorBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
connector_type: SearchSourceConnectorType
|
connector_type: SearchSourceConnectorType
|
||||||
is_indexable: bool
|
is_indexable: bool
|
||||||
last_indexed_at: datetime | None
|
last_indexed_at: Optional[datetime] = None
|
||||||
config: Dict[str, Any]
|
config: Dict[str, Any]
|
||||||
|
|
||||||
@field_validator('config')
|
@field_validator('config')
|
||||||
|
@ -59,8 +58,8 @@ class SearchSourceConnectorBase(BaseModel):
|
||||||
raise ValueError("NOTION_INTEGRATION_TOKEN cannot be empty")
|
raise ValueError("NOTION_INTEGRATION_TOKEN cannot be empty")
|
||||||
|
|
||||||
elif connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR:
|
elif connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR:
|
||||||
# For GITHUB_CONNECTOR, only allow GITHUB_PAT
|
# For GITHUB_CONNECTOR, only allow GITHUB_PAT and repo_full_names
|
||||||
allowed_keys = ["GITHUB_PAT"]
|
allowed_keys = ["GITHUB_PAT", "repo_full_names"]
|
||||||
if set(config.keys()) != set(allowed_keys):
|
if set(config.keys()) != set(allowed_keys):
|
||||||
raise ValueError(f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}")
|
raise ValueError(f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}")
|
||||||
|
|
||||||
|
@ -68,6 +67,10 @@ class SearchSourceConnectorBase(BaseModel):
|
||||||
if not config.get("GITHUB_PAT"):
|
if not config.get("GITHUB_PAT"):
|
||||||
raise ValueError("GITHUB_PAT cannot be empty")
|
raise ValueError("GITHUB_PAT cannot be empty")
|
||||||
|
|
||||||
|
# Ensure the repo_full_names is present and is a non-empty list
|
||||||
|
repo_full_names = config.get("repo_full_names")
|
||||||
|
if not isinstance(repo_full_names, list) or not repo_full_names:
|
||||||
|
raise ValueError("repo_full_names must be a non-empty list of strings")
|
||||||
elif connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR:
|
elif connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR:
|
||||||
# For LINEAR_CONNECTOR, only allow LINEAR_API_KEY
|
# For LINEAR_CONNECTOR, only allow LINEAR_API_KEY
|
||||||
allowed_keys = ["LINEAR_API_KEY"]
|
allowed_keys = ["LINEAR_API_KEY"]
|
||||||
|
@ -83,8 +86,12 @@ class SearchSourceConnectorBase(BaseModel):
|
||||||
class SearchSourceConnectorCreate(SearchSourceConnectorBase):
|
class SearchSourceConnectorCreate(SearchSourceConnectorBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class SearchSourceConnectorUpdate(SearchSourceConnectorBase):
|
class SearchSourceConnectorUpdate(BaseModel):
|
||||||
pass
|
name: Optional[str] = None
|
||||||
|
connector_type: Optional[SearchSourceConnectorType] = None
|
||||||
|
is_indexable: Optional[bool] = None
|
||||||
|
last_indexed_at: Optional[datetime] = None
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel):
|
class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel):
|
||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, Tuple
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
@ -639,24 +639,26 @@ async def index_github_repos(
|
||||||
if not connector:
|
if not connector:
|
||||||
return 0, f"Connector with ID {connector_id} not found or is not a GitHub connector"
|
return 0, f"Connector with ID {connector_id} not found or is not a GitHub connector"
|
||||||
|
|
||||||
# 2. Get the GitHub PAT from the connector config
|
# 2. Get the GitHub PAT and selected repositories from the connector config
|
||||||
github_pat = connector.config.get("GITHUB_PAT")
|
github_pat = connector.config.get("GITHUB_PAT")
|
||||||
|
repo_full_names_to_index = connector.config.get("repo_full_names")
|
||||||
|
|
||||||
if not github_pat:
|
if not github_pat:
|
||||||
return 0, "GitHub Personal Access Token (PAT) not found in connector config"
|
return 0, "GitHub Personal Access Token (PAT) not found in connector config"
|
||||||
|
|
||||||
|
if not repo_full_names_to_index or not isinstance(repo_full_names_to_index, list):
|
||||||
|
return 0, "'repo_full_names' not found or is not a list in connector config"
|
||||||
|
|
||||||
# 3. Initialize GitHub connector client
|
# 3. Initialize GitHub connector client
|
||||||
try:
|
try:
|
||||||
github_client = GitHubConnector(token=github_pat)
|
github_client = GitHubConnector(token=github_pat)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return 0, f"Failed to initialize GitHub client: {str(e)}"
|
return 0, f"Failed to initialize GitHub client: {str(e)}"
|
||||||
|
|
||||||
# 4. Get list of accessible repositories
|
# 4. Validate selected repositories
|
||||||
repositories = github_client.get_user_repositories()
|
# For simplicity, we'll proceed with the list provided.
|
||||||
if not repositories:
|
# If a repo is inaccessible, get_repository_files will likely fail gracefully later.
|
||||||
logger.info("No accessible GitHub repositories found for the provided token.")
|
logger.info(f"Starting indexing for {len(repo_full_names_to_index)} selected repositories.")
|
||||||
return 0, "No accessible GitHub repositories found."
|
|
||||||
|
|
||||||
logger.info(f"Found {len(repositories)} repositories to potentially index.")
|
|
||||||
|
|
||||||
# 5. Get existing documents for this search space and connector type to prevent duplicates
|
# 5. Get existing documents for this search space and connector type to prevent duplicates
|
||||||
existing_docs_result = await session.execute(
|
existing_docs_result = await session.execute(
|
||||||
|
@ -671,11 +673,10 @@ async def index_github_repos(
|
||||||
existing_docs_lookup = {doc.document_metadata.get("full_path"): doc for doc in existing_docs if doc.document_metadata.get("full_path")}
|
existing_docs_lookup = {doc.document_metadata.get("full_path"): doc for doc in existing_docs if doc.document_metadata.get("full_path")}
|
||||||
logger.info(f"Found {len(existing_docs_lookup)} existing GitHub documents in database for search space {search_space_id}")
|
logger.info(f"Found {len(existing_docs_lookup)} existing GitHub documents in database for search space {search_space_id}")
|
||||||
|
|
||||||
# 6. Iterate through repositories and index files
|
# 6. Iterate through selected repositories and index files
|
||||||
for repo_info in repositories:
|
for repo_full_name in repo_full_names_to_index:
|
||||||
repo_full_name = repo_info.get("full_name")
|
if not repo_full_name or not isinstance(repo_full_name, str):
|
||||||
if not repo_full_name:
|
logger.warning(f"Skipping invalid repository entry: {repo_full_name}")
|
||||||
logger.warning(f"Skipping repository with missing full_name: {repo_info.get('name')}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Processing repository: {repo_full_name}")
|
logger.info(f"Processing repository: {repo_full_name}")
|
||||||
|
|
|
@ -206,7 +206,7 @@ export default function ConnectorsPage() {
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}`)}
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`)}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
<span className="sr-only">Edit</span>
|
<span className="sr-only">Edit</span>
|
||||||
|
|
|
@ -0,0 +1,176 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
|
||||||
|
|
||||||
|
import { Form } from "@/components/ui/form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Import Utils, Types, Hook, and Components
|
||||||
|
import { getConnectorTypeDisplay } from '@/lib/connectors/utils';
|
||||||
|
import { useConnectorEditPage } from '@/hooks/useConnectorEditPage';
|
||||||
|
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
|
||||||
|
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
|
||||||
|
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
|
||||||
|
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
|
||||||
|
|
||||||
|
export default function EditConnectorPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const searchSpaceId = params.search_space_id as string;
|
||||||
|
// Ensure connectorId is parsed safely
|
||||||
|
const connectorIdParam = params.connector_id as string;
|
||||||
|
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||||
|
|
||||||
|
// Use the custom hook to manage state and logic
|
||||||
|
const {
|
||||||
|
connectorsLoading,
|
||||||
|
connector,
|
||||||
|
isSaving,
|
||||||
|
editForm,
|
||||||
|
patForm, // Needed for GitHub child component
|
||||||
|
handleSaveChanges,
|
||||||
|
// GitHub specific props for the child component
|
||||||
|
editMode,
|
||||||
|
setEditMode, // Pass down if needed by GitHub component
|
||||||
|
originalPat,
|
||||||
|
currentSelectedRepos,
|
||||||
|
fetchedRepos,
|
||||||
|
setFetchedRepos,
|
||||||
|
newSelectedRepos,
|
||||||
|
setNewSelectedRepos,
|
||||||
|
isFetchingRepos,
|
||||||
|
handleFetchRepositories,
|
||||||
|
handleRepoSelectionChange,
|
||||||
|
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||||
|
|
||||||
|
// Redirect if connectorId is not a valid number after parsing
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNaN(connectorId)) {
|
||||||
|
toast.error("Invalid Connector ID.");
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
|
}
|
||||||
|
}, [connectorId, router, searchSpaceId]);
|
||||||
|
|
||||||
|
// Loading State
|
||||||
|
if (connectorsLoading || !connector) {
|
||||||
|
// Handle NaN case before showing skeleton
|
||||||
|
if (isNaN(connectorId)) return null;
|
||||||
|
return <EditConnectorLoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Render using data/handlers from the hook
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
|
<Button variant="ghost" className="mb-6" onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}>
|
||||||
|
<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 }}>
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Github className="h-6 w-6" /> {/* TODO: Dynamic icon */}
|
||||||
|
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Modify connector name and configuration.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<Form {...editForm}>
|
||||||
|
{/* Pass hook's handleSaveChanges */}
|
||||||
|
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Pass form control from hook */}
|
||||||
|
<EditConnectorNameForm control={editForm.control} />
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold">Configuration</h3>
|
||||||
|
|
||||||
|
{/* == GitHub == */}
|
||||||
|
{connector.connector_type === 'GITHUB_CONNECTOR' && (
|
||||||
|
<EditGitHubConnectorConfig
|
||||||
|
// Pass relevant state and handlers from hook
|
||||||
|
editMode={editMode}
|
||||||
|
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||||
|
originalPat={originalPat}
|
||||||
|
currentSelectedRepos={currentSelectedRepos}
|
||||||
|
fetchedRepos={fetchedRepos}
|
||||||
|
newSelectedRepos={newSelectedRepos}
|
||||||
|
isFetchingRepos={isFetchingRepos}
|
||||||
|
patForm={patForm}
|
||||||
|
handleFetchRepositories={handleFetchRepositories}
|
||||||
|
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||||
|
setNewSelectedRepos={setNewSelectedRepos}
|
||||||
|
setFetchedRepos={setFetchedRepos}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* == Slack == */}
|
||||||
|
{connector.connector_type === 'SLACK_CONNECTOR' && (
|
||||||
|
<EditSimpleTokenForm
|
||||||
|
control={editForm.control}
|
||||||
|
fieldName="SLACK_BOT_TOKEN"
|
||||||
|
fieldLabel="Slack Bot Token"
|
||||||
|
fieldDescription="Update the Slack Bot Token if needed."
|
||||||
|
placeholder="Begins with xoxb-..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* == Notion == */}
|
||||||
|
{connector.connector_type === 'NOTION_CONNECTOR' && (
|
||||||
|
<EditSimpleTokenForm
|
||||||
|
control={editForm.control}
|
||||||
|
fieldName="NOTION_INTEGRATION_TOKEN"
|
||||||
|
fieldLabel="Notion Integration Token"
|
||||||
|
fieldDescription="Update the Notion Integration Token if needed."
|
||||||
|
placeholder="Begins with secret_..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* == Serper == */}
|
||||||
|
{connector.connector_type === 'SERPER_API' && (
|
||||||
|
<EditSimpleTokenForm
|
||||||
|
control={editForm.control}
|
||||||
|
fieldName="SERPER_API_KEY"
|
||||||
|
fieldLabel="Serper API Key"
|
||||||
|
fieldDescription="Update the Serper API Key if needed."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* == Tavily == */}
|
||||||
|
{connector.connector_type === 'TAVILY_API' && (
|
||||||
|
<EditSimpleTokenForm
|
||||||
|
control={editForm.control}
|
||||||
|
fieldName="TAVILY_API_KEY"
|
||||||
|
fieldLabel="Tavily API Key"
|
||||||
|
fieldDescription="Update the Tavily API Key if needed."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* == Linear == */}
|
||||||
|
{connector.connector_type === 'LINEAR_CONNECTOR' && (
|
||||||
|
<EditSimpleTokenForm
|
||||||
|
control={editForm.control}
|
||||||
|
fieldName="LINEAR_API_KEY"
|
||||||
|
fieldLabel="Linear API Key"
|
||||||
|
fieldDescription="Update your Linear API Key if needed."
|
||||||
|
placeholder="Begins with lin_api_..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="border-t pt-6">
|
||||||
|
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
||||||
|
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -70,7 +70,7 @@ export default function EditConnectorPage() {
|
||||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
console.log("connector", connector);
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<ApiConnectorFormValues>({
|
const form = useForm<ApiConnectorFormValues>({
|
||||||
resolver: zodResolver(apiConnectorFormSchema),
|
resolver: zodResolver(apiConnectorFormSchema),
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ArrowLeft, Check, Info, Loader2, Github } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2, Github, CircleAlert, ListChecks } from "lucide-react";
|
||||||
|
|
||||||
// Assuming useSearchSourceConnectors hook exists and works similarly
|
// Assuming useSearchSourceConnectors hook exists and works similarly
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
@ -42,9 +42,10 @@ import {
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
// Define the form schema with Zod for GitHub
|
// Define the form schema with Zod for GitHub PAT entry step
|
||||||
const githubConnectorFormSchema = z.object({
|
const githubPatFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
|
@ -58,61 +59,144 @@ const githubConnectorFormSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type GithubConnectorFormValues = z.infer<typeof githubConnectorFormSchema>;
|
type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
|
||||||
|
|
||||||
|
// Type for fetched GitHub repositories
|
||||||
|
interface GithubRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
private: boolean;
|
||||||
|
url: string;
|
||||||
|
description: string | null;
|
||||||
|
last_updated: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GithubConnectorPage() {
|
export default function GithubConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [step, setStep] = useState<'enter_pat' | 'select_repos'>('enter_pat');
|
||||||
const { createConnector } = useSearchSourceConnectors(); // Assuming this hook exists
|
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||||
|
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
|
||||||
|
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
|
||||||
|
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
|
||||||
|
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
|
||||||
|
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
|
||||||
|
|
||||||
// Initialize the form
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
const form = useForm<GithubConnectorFormValues>({
|
|
||||||
resolver: zodResolver(githubConnectorFormSchema),
|
// Initialize the form for PAT entry
|
||||||
|
const form = useForm<GithubPatFormValues>({
|
||||||
|
resolver: zodResolver(githubPatFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "GitHub Connector",
|
name: connectorName,
|
||||||
github_pat: "",
|
github_pat: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Function to fetch repositories using the new backend endpoint
|
||||||
const onSubmit = async (values: GithubConnectorFormValues) => {
|
const fetchRepositories = async (values: GithubPatFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsFetchingRepos(true);
|
||||||
|
setConnectorName(values.name); // Store the name
|
||||||
|
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('surfsense_bearer_token');
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('No authentication token found');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 errorData = await response.json();
|
||||||
|
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GithubRepo[] = await response.json();
|
||||||
|
setRepositories(data);
|
||||||
|
setStep('select_repos'); // Move to the next step
|
||||||
|
toast.success(`Found ${data.length} repositories.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching GitHub repositories:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Failed to fetch repositories. Please check the PAT and try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsFetchingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle final connector creation
|
||||||
|
const handleCreateConnector = async () => {
|
||||||
|
if (selectedRepos.length === 0) {
|
||||||
|
toast.warning("Please select at least one repository to index.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingConnector(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: connectorName, // Use the stored name
|
||||||
connector_type: "GITHUB_CONNECTOR",
|
connector_type: "GITHUB_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
GITHUB_PAT: values.github_pat,
|
GITHUB_PAT: validatedPat, // Use the stored validated PAT
|
||||||
|
repo_full_names: selectedRepos, // Add the selected repo names
|
||||||
},
|
},
|
||||||
is_indexable: true, // GitHub connector is indexable
|
is_indexable: true,
|
||||||
last_indexed_at: null, // New connector hasn't been indexed
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("GitHub connector created successfully!");
|
toast.success("GitHub connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors management page (or the add page)
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
} catch (error) { // Added type check for error
|
} catch (error) {
|
||||||
console.error("Error creating GitHub connector:", error);
|
console.error("Error creating GitHub connector:", error);
|
||||||
// Display specific backend error message if available
|
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector.";
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector. Please check the PAT and permissions.";
|
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsCreatingConnector(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle checkbox changes
|
||||||
|
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
|
||||||
|
setSelectedRepos(prev =>
|
||||||
|
checked
|
||||||
|
? [...prev, repoFullName]
|
||||||
|
: prev.filter(name => name !== repoFullName)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
onClick={() => {
|
||||||
|
if (step === 'select_repos') {
|
||||||
|
// Go back to PAT entry, clear sensitive/fetched data
|
||||||
|
setStep('enter_pat');
|
||||||
|
setRepositories([]);
|
||||||
|
setSelectedRepos([]);
|
||||||
|
setValidatedPat("");
|
||||||
|
// Reset form PAT field, keep name
|
||||||
|
form.reset({ name: connectorName, github_pat: "" });
|
||||||
|
} else {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Add Connectors
|
{step === 'select_repos' ? "Back to PAT Entry" : "Back to Add Connectors"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -129,30 +213,38 @@ export default function GithubConnectorPage() {
|
||||||
<TabsContent value="connect">
|
<TabsContent value="connect">
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold flex items-center gap-2"><Github className="h-6 w-6" /> Connect GitHub Account</CardTitle>
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
{step === 'enter_pat' ? <Github className="h-6 w-6" /> : <ListChecks className="h-6 w-6" />}
|
||||||
|
{step === 'enter_pat' ? "Connect GitHub Account" : "Select Repositories to Index"}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Integrate with GitHub using a Personal Access Token (PAT) to search and retrieve information from accessible repositories. This connector can index your code and documentation.
|
{step === 'enter_pat'
|
||||||
|
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
|
||||||
|
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`
|
||||||
|
}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
{step === 'enter_pat' && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Alert className="mb-6 bg-muted">
|
<Alert className="mb-6 bg-muted">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
|
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to use this connector. You can create one from your
|
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. You can create one from your{' '}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/settings/personal-access-tokens"
|
href="https://github.com/settings/personal-access-tokens"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium underline underline-offset-4 ml-1"
|
className="font-medium underline underline-offset-4"
|
||||||
>
|
>
|
||||||
GitHub Developer Settings
|
GitHub Developer Settings
|
||||||
</a>.
|
</a>. The PAT will be used to fetch repositories and then stored securely to enable indexing.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Form {...form}>
|
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
@ -184,7 +276,7 @@ export default function GithubConnectorPage() {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Your GitHub PAT will be encrypted and stored securely. Ensure it has the necessary 'repo' scopes.
|
Enter your GitHub PAT here to fetch your repositories. It will be stored encrypted later.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -194,32 +286,101 @@ export default function GithubConnectorPage() {
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isFetchingRepos}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isFetchingRepos ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Connecting...
|
Fetching Repositories...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
"Fetch Repositories"
|
||||||
<Check className="mr-2 h-4 w-4" />
|
|
||||||
Connect GitHub
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'select_repos' && (
|
||||||
|
<CardContent>
|
||||||
|
{repositories.length === 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlert className="h-4 w-4" />
|
||||||
|
<AlertTitle>No Repositories Found</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
No repositories were found or accessible with the provided PAT. Please check the token and its permissions, then go back and try again.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
|
||||||
|
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`repo-${repo.id}`}
|
||||||
|
checked={selectedRepos.includes(repo.full_name)}
|
||||||
|
onCheckedChange={(checked) => handleRepoSelection(repo.full_name, !!checked)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`repo-${repo.id}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{repo.full_name} {repo.private && "(Private)"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Select the repositories you wish to index. Only checked repositories will be processed.
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('enter_pat');
|
||||||
|
setRepositories([]);
|
||||||
|
setSelectedRepos([]);
|
||||||
|
setValidatedPat("");
|
||||||
|
form.reset({ name: connectorName, github_pat: "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateConnector}
|
||||||
|
disabled={isCreatingConnector || selectedRepos.length === 0}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isCreatingConnector ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Creating Connector...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
Create Connector
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
|
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
<li>Search through code and documentation in your repositories</li>
|
<li>Search through code and documentation in your selected repositories</li>
|
||||||
<li>Access READMEs, Markdown files, and common code files</li>
|
<li>Access READMEs, Markdown files, and common code files</li>
|
||||||
<li>Connect your project knowledge directly to your search space</li>
|
<li>Connect your project knowledge directly to your search space</li>
|
||||||
<li>Index your repositories for enhanced search capabilities</li>
|
<li>Index your selected repositories for enhanced search capabilities</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -237,27 +398,20 @@ export default function GithubConnectorPage() {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. It fetches information about repositories accessible to the token and indexes relevant files (code, markdown, text).
|
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. First, it fetches a list of repositories accessible to the token. You then select which repositories you want to index. The connector indexes relevant files (code, markdown, text) from only the selected repositories.
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||||
<li>The connector indexes files based on common code and documentation extensions.</li>
|
<li>The connector indexes files based on common code and documentation extensions.</li>
|
||||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
<li>Large files (over 1MB) are skipped during indexing.</li>
|
||||||
|
<li>Only selected repositories are indexed.</li>
|
||||||
<li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li>
|
<li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="create_pat">
|
<AccordionItem value="create_pat">
|
||||||
<AccordionTrigger className="text-lg font-medium">Step 1: Create a GitHub PAT</AccordionTrigger>
|
<AccordionTrigger className="text-lg font-medium">Step 1: Generate GitHub PAT</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4">
|
<AccordionContent>
|
||||||
<Alert className="bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>Token Security</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Treat your PAT like a password. Store it securely and consider using fine-grained tokens if possible.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium mb-2">Generating a Token:</h4>
|
<h4 className="font-medium mb-2">Generating a Token:</h4>
|
||||||
|
@ -280,9 +434,13 @@ export default function GithubConnectorPage() {
|
||||||
<AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger>
|
<AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4">
|
<AccordionContent className="space-y-4">
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
<li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field on the "Connect GitHub" tab.</li>
|
<li>Navigate to the "Connect GitHub" tab.</li>
|
||||||
<li>Optionally, give the connector a custom name.</li>
|
<li>Enter a name for your connector.</li>
|
||||||
<li>Click the <strong>Connect GitHub</strong> button.</li>
|
<li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field.</li>
|
||||||
|
<li>Click <strong>Fetch Repositories</strong>.</li>
|
||||||
|
<li>If the PAT is valid, you'll see a list of your accessible repositories.</li>
|
||||||
|
<li>Select the repositories you want SurfSense to index using the checkboxes.</li>
|
||||||
|
<li>Click the <strong>Create Connector</strong> button.</li>
|
||||||
<li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li>
|
<li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function EditConnectorLoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
|
<Skeleton className="h-8 w-48 mb-6" />
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-7 w-3/4 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Control } from 'react-hook-form';
|
||||||
|
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
// Assuming EditConnectorFormValues is defined elsewhere or passed as generic
|
||||||
|
interface EditConnectorNameFormProps {
|
||||||
|
control: Control<any>; // Use Control<EditConnectorFormValues> if type is available
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditConnectorNameForm({ control }: EditConnectorNameFormProps) {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Connector Name</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
|
import { FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Edit, KeyRound, Loader2, CircleAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
// Types needed from parent
|
||||||
|
interface GithubRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
private: boolean;
|
||||||
|
url: string;
|
||||||
|
description: string | null;
|
||||||
|
last_updated: string | null;
|
||||||
|
}
|
||||||
|
type GithubPatFormValues = { github_pat: string; };
|
||||||
|
type EditMode = 'viewing' | 'editing_repos';
|
||||||
|
|
||||||
|
interface EditGitHubConnectorConfigProps {
|
||||||
|
// State from parent
|
||||||
|
editMode: EditMode;
|
||||||
|
originalPat: string;
|
||||||
|
currentSelectedRepos: string[];
|
||||||
|
fetchedRepos: GithubRepo[] | null;
|
||||||
|
newSelectedRepos: string[];
|
||||||
|
isFetchingRepos: boolean;
|
||||||
|
// Forms from parent
|
||||||
|
patForm: UseFormReturn<GithubPatFormValues>;
|
||||||
|
// Handlers from parent
|
||||||
|
setEditMode: (mode: EditMode) => void;
|
||||||
|
handleFetchRepositories: (values: GithubPatFormValues) => Promise<void>;
|
||||||
|
handleRepoSelectionChange: (repoFullName: string, checked: boolean) => void;
|
||||||
|
setNewSelectedRepos: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
setFetchedRepos: React.Dispatch<React.SetStateAction<GithubRepo[] | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditGitHubConnectorConfig({
|
||||||
|
editMode,
|
||||||
|
originalPat,
|
||||||
|
currentSelectedRepos,
|
||||||
|
fetchedRepos,
|
||||||
|
newSelectedRepos,
|
||||||
|
isFetchingRepos,
|
||||||
|
patForm,
|
||||||
|
setEditMode,
|
||||||
|
handleFetchRepositories,
|
||||||
|
handleRepoSelectionChange,
|
||||||
|
setNewSelectedRepos,
|
||||||
|
setFetchedRepos
|
||||||
|
}: EditGitHubConnectorConfigProps) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-muted-foreground">Repository Selection & Access</h4>
|
||||||
|
|
||||||
|
{/* Viewing Mode */}
|
||||||
|
{editMode === 'viewing' && (
|
||||||
|
<div className="space-y-3 p-4 border rounded-md bg-muted/50">
|
||||||
|
<FormLabel>Currently Indexed Repositories:</FormLabel>
|
||||||
|
{currentSelectedRepos.length > 0 ? (
|
||||||
|
<ul className="list-disc pl-5 text-sm">
|
||||||
|
{currentSelectedRepos.map(repo => <li key={repo}>{repo}</li>)}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">(No repositories currently selected)</p>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setEditMode('editing_repos')}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" /> Change Selection / Update PAT
|
||||||
|
</Button>
|
||||||
|
<FormDescription>To change repo selections or update the PAT, click above.</FormDescription>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editing Mode */}
|
||||||
|
{editMode === 'editing_repos' && (
|
||||||
|
<div className="space-y-4 p-4 border rounded-md">
|
||||||
|
{/* PAT Input */}
|
||||||
|
<div className="flex items-end gap-4 p-4 border rounded-md bg-muted/90">
|
||||||
|
<FormField
|
||||||
|
control={patForm.control}
|
||||||
|
name="github_pat"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-grow">
|
||||||
|
<FormLabel className="flex items-center gap-1"><KeyRound className="h-4 w-4" /> GitHub PAT</FormLabel>
|
||||||
|
<FormControl><Input type="password" placeholder="ghp_... or github_pat_..." {...field} /></FormControl>
|
||||||
|
<FormDescription>Enter PAT to fetch/update repos or if you need to update the stored token.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isFetchingRepos}
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const isValid = await patForm.trigger('github_pat');
|
||||||
|
if (isValid) {
|
||||||
|
handleFetchRepositories(patForm.getValues());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFetchingRepos ? <Loader2 className="h-4 w-4 animate-spin" /> : "Fetch Repositories"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repo List */}
|
||||||
|
{isFetchingRepos && <Skeleton className="h-40 w-full" />}
|
||||||
|
{!isFetchingRepos && fetchedRepos !== null && (
|
||||||
|
fetchedRepos.length === 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlert className="h-4 w-4" />
|
||||||
|
<AlertTitle>No Repositories Found</AlertTitle>
|
||||||
|
<AlertDescription>Check PAT & permissions.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>Select Repositories to Index ({newSelectedRepos.length} selected):</FormLabel>
|
||||||
|
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
|
||||||
|
{fetchedRepos.map((repo) => (
|
||||||
|
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`repo-${repo.id}`}
|
||||||
|
checked={newSelectedRepos.includes(repo.full_name)}
|
||||||
|
onCheckedChange={(checked) => handleRepoSelectionChange(repo.full_name, !!checked)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`repo-${repo.id}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{repo.full_name} {repo.private && "(Private)"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditMode('viewing');
|
||||||
|
setFetchedRepos(null);
|
||||||
|
setNewSelectedRepos(currentSelectedRepos);
|
||||||
|
patForm.reset({ github_pat: originalPat }); // Reset PAT form on cancel
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel Repo Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Control } from 'react-hook-form';
|
||||||
|
import { FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { KeyRound } from 'lucide-react';
|
||||||
|
|
||||||
|
// Assuming EditConnectorFormValues is defined elsewhere or passed as generic
|
||||||
|
interface EditSimpleTokenFormProps {
|
||||||
|
control: Control<any>;
|
||||||
|
fieldName: string; // e.g., "SLACK_BOT_TOKEN"
|
||||||
|
fieldLabel: string; // e.g., "Slack Bot Token"
|
||||||
|
fieldDescription: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditSimpleTokenForm({
|
||||||
|
control,
|
||||||
|
fieldName,
|
||||||
|
fieldLabel,
|
||||||
|
fieldDescription,
|
||||||
|
placeholder
|
||||||
|
}: EditSimpleTokenFormProps) {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={fieldName}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-1"><KeyRound className="h-4 w-4" /> {fieldLabel}</FormLabel>
|
||||||
|
<FormControl><Input type="password" placeholder={placeholder} {...field} /></FormControl>
|
||||||
|
<FormDescription>{fieldDescription}</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
34
surfsense_web/components/editConnector/types.ts
Normal file
34
surfsense_web/components/editConnector/types.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface GithubRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
private: boolean;
|
||||||
|
url: string;
|
||||||
|
description: string | null;
|
||||||
|
last_updated: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditMode = 'viewing' | 'editing_repos';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
export const githubPatSchema = z.object({
|
||||||
|
github_pat: z.string()
|
||||||
|
.min(20, { message: "GitHub Personal Access Token seems too short." })
|
||||||
|
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), {
|
||||||
|
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
export type GithubPatFormValues = z.infer<typeof githubPatSchema>;
|
||||||
|
|
||||||
|
export const editConnectorSchema = z.object({
|
||||||
|
name: z.string().min(3, { message: "Connector name must be at least 3 characters." }),
|
||||||
|
SLACK_BOT_TOKEN: z.string().optional(),
|
||||||
|
NOTION_INTEGRATION_TOKEN: z.string().optional(),
|
||||||
|
SERPER_API_KEY: z.string().optional(),
|
||||||
|
TAVILY_API_KEY: z.string().optional(),
|
||||||
|
LINEAR_API_KEY: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;
|
240
surfsense_web/hooks/useConnectorEditPage.ts
Normal file
240
surfsense_web/hooks/useConnectorEditPage.ts
Normal file
|
@ -0,0 +1,240 @@
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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: ""
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 || ""
|
||||||
|
});
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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 }) }
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
12
surfsense_web/lib/connectors/utils.ts
Normal file
12
surfsense_web/lib/connectors/utils.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
// Helper function to get connector type display name
|
||||||
|
export const getConnectorTypeDisplay = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
"SERPER_API": "Serper API",
|
||||||
|
"TAVILY_API": "Tavily API",
|
||||||
|
"SLACK_CONNECTOR": "Slack",
|
||||||
|
"NOTION_CONNECTOR": "Notion",
|
||||||
|
"GITHUB_CONNECTOR": "GitHub",
|
||||||
|
"LINEAR_CONNECTOR": "Linear",
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue