Merge remote-tracking branch 'upstream/dev' into sur-70-feature-streamline-onboarding-auto-create-default-workspace

This commit is contained in:
CREDO23 2026-01-09 14:56:41 +02:00
commit a5d47cae31
133 changed files with 5763 additions and 2603 deletions

View file

@ -5,7 +5,7 @@ import { Navbar } from "@/components/homepage/navbar";
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar />
{children}
<FooterNew />

View file

@ -28,7 +28,7 @@ import {
} from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createInviteMutationAtom,
@ -116,6 +116,7 @@ import type {
} from "@/contracts/types/roles.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service";
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@ -297,6 +298,14 @@ export default function TeamManagementPage() {
toast.success("Team data refreshed");
}, [fetchMembers, fetchRoles, fetchInvites]);
// Track users per search space when team page is viewed
useEffect(() => {
if (members.length > 0 && !membersLoading) {
const ownerCount = members.filter((m) => m.is_owner).length;
trackSearchSpaceUsersViewed(searchSpaceId, members.length, ownerCount);
}
}, [members, membersLoading, searchSpaceId]);
if (accessLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@ -1088,10 +1097,12 @@ function InvitesTab({
function CreateInviteDialog({
roles,
onCreateInvite,
searchSpaceId,
className,
}: {
roles: Role[];
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
searchSpaceId: number;
className?: string;
}) {
const [open, setOpen] = useState(false);
@ -1114,6 +1125,17 @@ function CreateInviteDialog({
const invite = await onCreateInvite(data);
setCreatedInvite(invite);
// Track invite sent event
const roleName =
roleId && roleId !== "default"
? roles.find((r) => r.id.toString() === roleId)?.name
: undefined;
trackSearchSpaceInviteSent(searchSpaceId, {
roleName,
hasExpiry: !!expiresAt,
hasMaxUses: !!maxUses,
});
} catch (error) {
console.error("Failed to create invite:", error);
} finally {

View file

@ -181,7 +181,7 @@ const DashboardPage = () => {
email:
user?.email ||
(isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
avatar: "/icon-128.png", // Default avatar
avatar: "/icon-128.svg", // Default avatar
};
// Show loading while loading or auto-redirecting (single search space)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -33,6 +33,11 @@ import {
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { invitesApiService } from "@/lib/apis/invites-api.service";
import { getBearerToken } from "@/lib/auth-utils";
import {
trackSearchSpaceInviteAccepted,
trackSearchSpaceInviteDeclined,
trackSearchSpaceUserAdded,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export default function InviteAcceptPage() {
@ -91,6 +96,18 @@ export default function InviteAcceptPage() {
if (result) {
setAccepted(true);
setAcceptedData(result);
// Track invite accepted and user added events
trackSearchSpaceInviteAccepted(
result.search_space_id,
result.search_space_name,
result.role_name
);
trackSearchSpaceUserAdded(
result.search_space_id,
result.search_space_name,
result.role_name
);
}
} catch (err: any) {
setError(err.message || "Failed to accept invite");
@ -99,6 +116,12 @@ export default function InviteAcceptPage() {
}
};
const handleDecline = () => {
// Track invite declined event
trackSearchSpaceInviteDeclined(inviteInfo?.search_space_name);
router.push("/dashboard");
};
const handleLoginRedirect = () => {
// Store the invite code to redirect back after login
localStorage.setItem("pending_invite_code", inviteCode);
@ -324,11 +347,7 @@ export default function InviteAcceptPage() {
)}
</CardContent>
<CardFooter className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => router.push("/dashboard")}
>
<Button variant="outline" className="flex-1" onClick={handleDecline}>
Cancel
</Button>
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
@ -360,7 +379,7 @@ export default function InviteAcceptPage() {
href="/"
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<Image src="/icon-128.png" alt="SurfSense" width={24} height={24} className="rounded" />
<Image src="/icon-128.svg" alt="SurfSense" width={24} height={24} className="rounded" />
<span className="text-sm font-medium">SurfSense</span>
</Link>
</motion.div>

View file

@ -1,60 +1,179 @@
import type { MetadataRoute } from "next";
// Returns a date rounded to the current hour (updates only once per hour)
function getHourlyDate(): Date {
const now = new Date();
now.setMinutes(0, 0, 0);
return now;
}
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = getHourlyDate();
return [
{
url: "https://www.surfsense.com/",
lastModified: new Date(),
changeFrequency: "yearly",
lastModified,
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/contact",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/pricing",
lastModified: new Date(),
changeFrequency: "yearly",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/privacy",
lastModified: new Date(),
changeFrequency: "monthly",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/terms",
lastModified: new Date(),
changeFrequency: "monthly",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
// Documentation pages
{
url: "https://www.surfsense.com/docs",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
lastModified,
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/docs/installation",
lastModified: new Date(),
changeFrequency: "weekly",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation",
lastModified: new Date(),
changeFrequency: "weekly",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/manual-installation",
lastModified: new Date(),
changeFrequency: "weekly",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
// Connector documentation
{
url: "https://www.surfsense.com/docs/connectors/airtable",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/bookstack",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/circleback",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/clickup",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/confluence",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/discord",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/elasticsearch",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/github",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/gmail",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-calendar",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-drive",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/jira",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/linear",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/luma",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-teams",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/notion",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/slack",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/web-crawler",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
];
}

View file

@ -0,0 +1,48 @@
---
title: "SurfSense v0.0.11 - Connectors And More Connectors"
description: "SurfSense v0.0.11 delivers powerful new integrations for our AI enterprise search platform, including Google Drive and Circleback connectors, multi-account support, and a fully responsive mobile interface."
date: "2026-01-08"
tags: ["Mobile", "UX", "Integrations", "Connectors"]
version: "0.0.11"
---
<img src="/changelog/0.0.11/header.gif" alt="SurfSense v0.0.11 - Connectors And More Connectors" className="rounded-lg w-full" />
## What's New in v0.0.11
This release focuses on **connectivity and ease of use** for your enterprise search software. We've begun a comprehensive UX overhaul, streamlining how you connect your data sources, alongside a fully responsive mobile interface that lets you access SurfSense's AI enterprise search capabilities from anywhere.
### New Features
- **Mobile-Ready Interface**: A fully responsive UI implementation allows you to search and collaborate seamlessly from your mobile device, bringing enterprise search solutions to your pocket.
- **Streamlined Connector Management**: We've simplified the connector setup and management pages as part of a larger, ongoing UX overhaul for a smoother experience.
- **Google Drive Integration**: Added a dedicated connector for Google Drive, featuring granular file selection to index only what you need for precise enterprise search.
- **Circleback Support**: Introducing a new connector for Circleback to integrate your meeting notes and insights into your unified knowledge base.
- **Simplified OAuth Authentication**: All supported connectors have been migrated to OAuth, making setup faster and more secure across your enterprise search software stack.
- **Multi-Account Support**: Connect multiple accounts for the same service (e.g., Personal and Work Google Drives) to unify all your data sources in one AI-powered search hub.
<Accordion type="multiple" className="w-full not-prose">
<AccordionItem value="item-1">
<AccordionTrigger>Bug Fixes</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>Fixed a login issue affecting specific Google accounts on surfsense.com</li>
<li>Resolved most Docker self-hosting configuration issues for easier deployment</li>
</ul>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>For Self-Hosters</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>Docker configuration has been streamlined for smoother self-hosted deployments</li>
<li>OAuth setup is now consistent across all connectors</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
SurfSense is an open-source AI enterprise search solution that connects all your knowledge sources, from Google Drive to Slack to meeting notes, in one intelligent, federated search platform. Whether you're looking for enterprise search software for your team or a personal knowledge assistant, SurfSense delivers powerful enterprise search solutions with the flexibility of self-hosting.
🚀 Connect more, search smarter!

View file

@ -7,7 +7,7 @@ import { cn } from "@/lib/utils";
export const Logo = ({ className }: { className?: string }) => {
return (
<Link href="/">
<Image src="/icon-128.png" className={cn(className)} alt="logo" width={128} height={128} />
<Image src="/icon-128.svg" className={cn("dark:invert", className)} alt="logo" width={128} height={128} />
</Link>
);
};

View file

@ -19,9 +19,11 @@ import { ConnectorDialogHeader } from "./connector-popup/components/connector-di
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => {
@ -60,6 +62,7 @@ export const ConnectorIndicator: FC = () => {
periodicEnabled,
frequencyMinutes,
allConnectors,
viewingAccountsType,
setSearchQuery,
setStartDate,
setEndDate,
@ -81,6 +84,8 @@ export const ConnectorIndicator: FC = () => {
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
@ -194,6 +199,25 @@ export const ConnectorIndicator: FC = () => {
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
connectorTitle={viewingAccountsType.connectorTitle}
connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
const oauthConnector = OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) {
handleConnectOAuth(oauthConnector);
}
}}
isConnecting={connectingId !== null}
/>
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
@ -224,7 +248,8 @@ export const ConnectorIndicator: FC = () => {
onBack={handleBackFromEdit}
onQuickIndex={
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => handleQuickIndexConnector(editingConnector.id)
? () =>
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type)
: undefined
}
onConfigChange={setConnectorConfig}
@ -289,6 +314,7 @@ export const ConnectorIndicator: FC = () => {
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</TabsContent>
@ -303,6 +329,7 @@ export const ConnectorIndicator: FC = () => {
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
</div>

View file

@ -17,6 +17,7 @@ interface ConnectorCardProps {
isConnected?: boolean;
isConnecting?: boolean;
documentCount?: number;
accountCount?: number;
lastIndexedAt?: string | null;
isIndexing?: boolean;
activeTask?: LogActiveTask;
@ -96,6 +97,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
isConnected = false,
isConnecting = false,
documentCount,
accountCount,
lastIndexedAt,
isIndexing = false,
activeTask,
@ -139,7 +141,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10">
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors flex-shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
@ -150,12 +152,20 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[14px] font-semibold leading-tight">{title}</span>
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
{accountCount !== undefined && accountCount > 0 && (
<>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</>
)}
</p>
)}
</div>
@ -163,7 +173,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
"h-8 text-[11px] px-3 rounded-lg flex-shrink-0 font-medium",
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium",
isConnected &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
!isConnected && "shadow-xs"

View file

@ -1,385 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const clickupConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_token: z.string().min(10, {
message: "ClickUp API Token is required and must be valid.",
}),
});
type ClickUpConnectorFormValues = z.infer<typeof clickupConnectorFormSchema>;
export const ClickUpConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<ClickUpConnectorFormValues>({
resolver: zodResolver(clickupConnectorFormSchema),
defaultValues: {
name: "ClickUp Connector",
api_token: "",
},
});
const handleSubmit = async (values: ClickUpConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
config: {
CLICKUP_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a ClickUp API Token to use this connector. You can create one from{" "}
<a
href="https://app.clickup.com/settings/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
ClickUp Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="clickup-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My ClickUp Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">ClickUp API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="pk_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your ClickUp API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with ClickUp integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The ClickUp connector uses the ClickUp API to fetch all tasks and projects that your
API token has access to within your workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves tasks that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need a ClickUp personal API token to use this connector. The token will be
used to read your ClickUp data.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get Your API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your ClickUp account</li>
<li>Click your avatar in the upper-right corner and select "Settings"</li>
<li>In the sidebar, click "Apps"</li>
<li>
Under "API Token", click <strong>Generate</strong> or{" "}
<strong>Regenerate</strong>
</li>
<li>Copy the generated token (it typically starts with "pk_")</li>
<li>
Paste it in the form above. You can also visit{" "}
<a
href="https://app.clickup.com/settings/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
ClickUp API Settings
</a>{" "}
directly.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API Token will have access to all tasks and projects that your user
account can see. Make sure your account has appropriate permissions for the
workspaces you want to index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only tasks, comments, and basic metadata will be indexed. ClickUp
attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>ClickUp</strong>{" "}
Connector.
</li>
<li>
Place your <strong>API Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your ClickUp tasks will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The ClickUp connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Task names and descriptions</li>
<li>Task comments and discussion threads</li>
<li>Task status, priority, and assignee information</li>
<li>Project and workspace information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -2,7 +2,6 @@ import type { FC } from "react";
import { BaiduSearchApiConnectForm } from "./components/baidu-search-api-connect-form";
import { BookStackConnectForm } from "./components/bookstack-connect-form";
import { CirclebackConnectForm } from "./components/circleback-connect-form";
import { ClickUpConnectForm } from "./components/clickup-connect-form";
import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form";
import { GithubConnectForm } from "./components/github-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
@ -51,8 +50,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return BookStackConnectForm;
case "GITHUB_CONNECTOR":
return GithubConnectForm;
case "CLICKUP_CONNECTOR":
return ClickUpConnectForm;
case "LUMA_CONNECTOR":
return LumaConnectForm;
case "CIRCLEBACK_CONNECTOR":

View file

@ -1,6 +1,6 @@
"use client";
import { KeyRound } from "lucide-react";
import { Info, KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
@ -16,17 +16,22 @@ export const ClickUpConfig: FC<ClickUpConfigProps> = ({
onConfigChange,
onNameChange,
}) => {
// Check if this is an OAuth connector (has access_token or _token_encrypted flag)
const isOAuth = !!(connector.config?.access_token || connector.config?._token_encrypted);
const [apiToken, setApiToken] = useState<string>(
(connector.config?.CLICKUP_API_TOKEN as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update API token and name when connector changes
// Update values when connector changes (only for legacy connectors)
useEffect(() => {
const token = (connector.config?.CLICKUP_API_TOKEN as string) || "";
setApiToken(token);
if (!isOAuth) {
const token = (connector.config?.CLICKUP_API_TOKEN as string) || "";
setApiToken(token);
}
setName(connector.name || "");
}, [connector.config, connector.name]);
}, [connector.config, connector.name, isOAuth]);
const handleApiTokenChange = (value: string) => {
setApiToken(value);
@ -45,6 +50,32 @@ export const ClickUpConfig: FC<ClickUpConfigProps> = ({
}
};
// For OAuth connectors, show simple info message
if (isOAuth) {
const workspaceName = (connector.config?.workspace_name as string) || "Unknown Workspace";
return (
<div className="space-y-6">
{/* OAuth Info */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Connected via OAuth</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
Workspace:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-inherit">{workspaceName}</code>
</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
To update your connection, reconnect this connector.
</p>
</div>
</div>
</div>
);
}
// For legacy API token connectors, show the form
return (
<div className="space-y-6">
{/* Connector Name */}
@ -82,7 +113,8 @@ export const ClickUpConfig: FC<ClickUpConfigProps> = ({
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your ClickUp API Token if needed.
Update your ClickUp API Token if needed. For better security and automatic token
refresh, consider disconnecting and reconnecting using OAuth 2.0.
</p>
</div>
</div>

View file

@ -0,0 +1,29 @@
"use client";
import { Info } from "lucide-react";
import type { FC } from "react";
import type { ConnectorConfigProps } from "../index";
export interface TeamsConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const TeamsConfig: FC<TeamsConfigProps> = () => {
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
SurfSense will index messages from Teams channels that you have access to. The app can
only read messages from teams and channels where you are a member. Make sure you're a
member of the teams you want to index before connecting.
</p>
</div>
</div>
</div>
);
};

View file

@ -17,6 +17,7 @@ import { LumaConfig } from "./components/luma-config";
import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
import { TeamsConfig } from "./components/teams-config";
import { WebcrawlerConfig } from "./components/webcrawler-config";
export interface ConnectorConfigProps {
@ -52,6 +53,8 @@ export function getConnectorConfigComponent(
return SlackConfig;
case "DISCORD_CONNECTOR":
return DiscordConfig;
case "TEAMS_CONNECTOR":
return TeamsConfig;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR":

View file

@ -54,7 +54,6 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
BOOKSTACK_CONNECTOR: "bookstack-connect-form",
GITHUB_CONNECTOR: "github-connect-form",
CLICKUP_CONNECTOR: "clickup-connect-form",
LUMA_CONNECTOR: "luma-connect-form",
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
};

View file

@ -143,12 +143,14 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Connector header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
<div className="flex items-center gap-4 flex-1 w-full sm:w-auto">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
<div className="flex gap-4 flex-1 w-full sm:w-auto">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20 shrink-0">
{getConnectorIcon(connector.connector_type, "size-7")}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">{connector.name}</h2>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{connector.name}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
</p>

View file

@ -1,14 +1,16 @@
"use client";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import type { IndexingConfigState } from "../../constants/connector-constants";
import { type IndexingConfigState, OAUTH_CONNECTORS } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index";
interface IndexingConfigurationViewProps {
@ -89,12 +91,14 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
};
}, [checkScrollState]);
const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */}
<div
className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
"shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-sm"
)}
>
@ -111,14 +115,19 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
)}
{/* Success header */}
<div className="flex items-center gap-4 mb-6">
<div className="flex gap-4 mb-6">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-green-500/10 border border-green-500/20">
<Check className="size-7 text-green-500" />
</div>
<div>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
{config.connectorTitle} Connected!
</h2>
<div className="flex flex-col">
<span className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{getConnectorTypeDisplay(connector?.connector_type || "")} Connected !
</span>{" "}
<span className="text-xl sm:text-xl font-semibold text-muted-foreground tracking-tight text-wrap whitespace-normal wrap-break-word">
{getConnectorDisplayName(connector?.name || "")}
</span>
</div>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Configure when to start syncing your data
</p>

View file

@ -51,6 +51,13 @@ export const OAUTH_CONNECTORS = [
connectorType: EnumConnectorName.SLACK_CONNECTOR,
authEndpoint: "/api/v1/auth/slack/connector/add/",
},
{
id: "teams-connector",
title: "Microsoft Teams",
description: "Search Teams messages",
connectorType: EnumConnectorName.TEAMS_CONNECTOR,
authEndpoint: "/api/v1/auth/teams/connector/add/",
},
{
id: "discord-connector",
title: "Discord",
@ -72,6 +79,13 @@ export const OAUTH_CONNECTORS = [
connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR,
authEndpoint: "/api/v1/auth/confluence/connector/add/",
},
{
id: "clickup-connector",
title: "ClickUp",
description: "Search ClickUp tasks",
connectorType: EnumConnectorName.CLICKUP_CONNECTOR,
authEndpoint: "/api/v1/auth/clickup/connector/add/",
},
] as const;
// Content Sources (tools that extract and import content from external sources)
@ -104,12 +118,6 @@ export const OTHER_CONNECTORS = [
description: "Search repositories",
connectorType: EnumConnectorName.GITHUB_CONNECTOR,
},
{
id: "clickup-connector",
title: "ClickUp",
description: "Search ClickUp tasks",
connectorType: EnumConnectorName.CLICKUP_CONNECTOR,
},
{
id: "luma-connector",
title: "Luma",

View file

@ -7,11 +7,12 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube", "accounts"]).optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),
success: z.enum(["true", "false"]).optional(),
error: z.string().optional(),
});
export type ConnectorPopupQueryParams = z.infer<typeof connectorPopupQueryParamsSchema>;

View file

@ -15,6 +15,14 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { searchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import {
trackConnectorConnected,
trackConnectorDeleted,
trackIndexWithDateRangeOpened,
trackIndexWithDateRangeStarted,
trackPeriodicIndexingStarted,
trackQuickIndexClicked,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
import type { IndexingConfigState } from "../constants/connector-constants";
@ -66,6 +74,12 @@ export const useConnectorDialog = () => {
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const isCreatingConnectorRef = useRef(false);
// Accounts list view state (for OAuth connectors with multiple accounts)
const [viewingAccountsType, setViewingAccountsType] = useState<{
connectorType: string;
connectorTitle: string;
} | null>(null);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@ -114,24 +128,50 @@ export const useConnectorDialog = () => {
setConnectingConnectorType(null);
}
// Clear viewing accounts type if view is not "accounts" anymore
if (params.view !== "accounts" && viewingAccountsType) {
setViewingAccountsType(null);
}
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
}
// Handle accounts view
if (params.view === "accounts" && params.connectorType && !viewingAccountsType) {
const oauthConnector = OAUTH_CONNECTORS.find(
(c) => c.connectorType === params.connectorType
);
if (oauthConnector) {
setViewingAccountsType({
connectorType: oauthConnector.connectorType,
connectorTitle: oauthConnector.title,
});
}
}
// Handle YouTube view
if (params.view === "youtube") {
// YouTube view is active - no additional state needed
}
if (params.view === "configure" && params.connector && !indexingConfig) {
// Handle configure view (for page refresh support)
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector && allConnectors) {
const existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
if (oauthConnector) {
let existingConnector: SearchSourceConnector | undefined;
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.id === connectorId
);
} else {
existingConnector = allConnectors.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
}
if (existingConnector) {
// Validate connector data before setting state
const connectorValidation = searchSourceConnector.safeParse(existingConnector);
if (connectorValidation.success) {
const config = validateIndexingConfigState({
@ -200,6 +240,10 @@ export const useConnectorDialog = () => {
if (connectingConnectorType) {
setConnectingConnectorType(null);
}
// Clear viewing accounts type when modal is closed
if (viewingAccountsType) {
setViewingAccountsType(null);
}
// Clear YouTube view when modal is closed (handled by view param check)
}
} catch (error) {
@ -207,13 +251,48 @@ export const useConnectorDialog = () => {
console.warn("Invalid connector popup query params:", error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType]);
}, [
searchParams,
allConnectors,
editingConnector,
indexingConfig,
connectingConnectorType,
viewingAccountsType,
]);
// Detect OAuth success and transition to config view
// Detect OAuth success / Failure and transition to config view
useEffect(() => {
try {
const params = parseConnectorPopupQueryParams(searchParams);
// Handle OAuth errors (e.g., duplicate account)
if (params.error && params.modal === "connectors") {
const oauthConnector = params.connector
? OAUTH_CONNECTORS.find((c) => c.id === params.connector)
: null;
const connectorName = oauthConnector?.title || "connector";
if (params.error === "duplicate_account") {
toast.error(`This ${connectorName} account is already connected`, {
description: "Please use a different account or manage the existing connection.",
});
} else {
toast.error(`Failed to connect ${connectorName}`, {
description: params.error.replace(/_/g, " "),
});
}
// Clean up error params from URL
const url = new URL(window.location.href);
url.searchParams.delete("error");
url.searchParams.delete("connector");
window.history.replaceState({}, "", url.toString());
// Open the popup to show the connectors
setIsOpen(true);
return;
}
if (
params.success === "true" &&
params.connector &&
@ -225,13 +304,26 @@ export const useConnectorDialog = () => {
refetchAllConnectors().then((result) => {
if (!result.data) return;
const newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
let newConnector: SearchSourceConnector | undefined;
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
} else {
newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
);
}
if (newConnector) {
// Validate connector data before setting state
const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) {
// Track connector connected event for OAuth connectors
trackConnectorConnected(
Number(searchSpaceId),
oauthConnector.connectorType,
newConnector.id
);
const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType,
connectorId: newConnector.id,
@ -243,6 +335,7 @@ export const useConnectorDialog = () => {
setIsOpen(true);
const url = new URL(window.location.href);
url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString());
} else {
@ -341,6 +434,13 @@ export const useConnectorDialog = () => {
if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) {
// Track webcrawler connector connected
trackConnectorConnected(
Number(searchSpaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
connector.id
);
const config = validateIndexingConfigState({
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
connectorId: connector.id,
@ -436,6 +536,9 @@ export const useConnectorDialog = () => {
// Store connectingConnectorType before clearing it
const currentConnectorType = connectingConnectorType;
// Track connector connected event for non-OAuth connectors
trackConnectorConnected(Number(searchSpaceId), currentConnectorType, connector.id);
// Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find(
(c) => c.connectorType === currentConnectorType
@ -632,6 +735,38 @@ export const useConnectorDialog = () => {
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle viewing accounts list for OAuth connector type
const handleViewAccountsList = useCallback(
(connectorType: string, connectorTitle: string) => {
if (!searchSpaceId) return;
setViewingAccountsType({
connectorType,
connectorTitle,
});
// Update URL to show accounts view, preserving current tab
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", connectorType);
// Keep the current tab in URL so we can go back to it
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId]
);
// Handle going back from accounts list view
const handleBackFromAccountsList = useCallback(() => {
setViewingAccountsType(null);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
// Keep the current tab (don't change it) - just remove view-specific params
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle starting indexing
const handleStartIndexing = useCallback(
async (refreshConnectors: () => void) => {
@ -738,6 +873,27 @@ export const useConnectorDialog = () => {
});
}
// Track index with date range started event
trackIndexWithDateRangeStarted(
Number(searchSpaceId),
indexingConfig.connectorType,
indexingConfig.connectorId,
{
hasStartDate: !!startDate,
hasEndDate: !!endDate,
}
);
// Track periodic indexing started if enabled
if (periodicEnabled && indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR") {
trackPeriodicIndexingStarted(
Number(searchSpaceId),
indexingConfig.connectorType,
indexingConfig.connectorId,
parseInt(frequencyMinutes, 10)
);
}
toast.success(`${indexingConfig.connectorTitle} indexing started`, {
description: periodicEnabled
? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.`
@ -804,6 +960,15 @@ export const useConnectorDialog = () => {
return;
}
// Track index with date range opened event
if (connector.is_indexable) {
trackIndexWithDateRangeOpened(
Number(searchSpaceId),
connector.connector_type,
connector.id
);
}
setEditingConnector(connector);
setConnectorName(connector.name);
// Load existing periodic sync settings (disabled for Google Drive and non-indexable connectors)
@ -939,6 +1104,36 @@ export const useConnectorDialog = () => {
indexingDescription = "Re-indexing started with new date range.";
}
// Track indexing started if re-indexing was performed
if (
editingConnector.is_indexable &&
(indexingDescription.includes("Re-indexing") || indexingDescription.includes("indexing"))
) {
trackIndexWithDateRangeStarted(
Number(searchSpaceId),
editingConnector.connector_type,
editingConnector.id,
{
hasStartDate: !!startDateStr,
hasEndDate: !!endDateStr,
}
);
}
// Track periodic indexing if enabled (for non-Google Drive connectors)
if (
periodicEnabled &&
editingConnector.is_indexable &&
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
) {
trackPeriodicIndexingStarted(
Number(searchSpaceId),
editingConnector.connector_type,
editingConnector.id,
frequency || parseInt(frequencyMinutes, 10)
);
}
toast.success(`${editingConnector.name} updated successfully`, {
description: periodicEnabled
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
@ -991,6 +1186,13 @@ export const useConnectorDialog = () => {
id: editingConnector.id,
});
// Track connector deleted event
trackConnectorDeleted(
Number(searchSpaceId),
editingConnector.connector_type,
editingConnector.id
);
toast.success(`${editingConnector.name} disconnected successfully`);
// Update URL - the effect will handle closing the modal and clearing state
@ -1017,9 +1219,14 @@ export const useConnectorDialog = () => {
// Handle quick index (index without date picker, uses backend defaults)
const handleQuickIndexConnector = useCallback(
async (connectorId: number) => {
async (connectorId: number, connectorType?: string) => {
if (!searchSpaceId) return;
// Track quick index clicked event
if (connectorType) {
trackQuickIndexClicked(Number(searchSpaceId), connectorType, connectorId);
}
try {
await indexConnector({
connector_id: connectorId,
@ -1081,6 +1288,7 @@ export const useConnectorDialog = () => {
setConnectorName(null);
setConnectorConfig(null);
setConnectingConnectorType(null);
setViewingAccountsType(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
@ -1126,6 +1334,7 @@ export const useConnectorDialog = () => {
frequencyMinutes,
searchSpaceId,
allConnectors,
viewingAccountsType,
// Setters
setSearchQuery,
@ -1152,6 +1361,8 @@ export const useConnectorDialog = () => {
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,

View file

@ -11,6 +11,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
interface ActiveConnectorsTabProps {
@ -24,6 +25,7 @@ interface ActiveConnectorsTabProps {
searchSpaceId: string;
onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
@ -36,6 +38,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchSpaceId,
onTabChange,
onManage,
onViewAccountsList,
}) => {
const router = useRouter();
@ -71,38 +74,26 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
if (minutesAgo < 1) return "Just now";
if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
if (isToday(date)) return `Today at ${format(date, "h:mm a")}`;
if (isYesterday(date)) return `Yesterday at ${format(date, "h:mm a")}`;
if (daysAgo < 7) return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
return format(date, "MMM d, yyyy");
};
// Document types that should be shown as cards (not from connectors)
// These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes),
// YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR)
// Get most recent last indexed date from a list of connectors
const getMostRecentLastIndexed = (
connectorsList: SearchSourceConnector[]
): string | undefined => {
return connectorsList.reduce<string | undefined>((latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest;
}, undefined);
};
// Document types that should be shown as standalone cards (not from connectors)
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
// Filter to only show standalone document types that have documents (count > 0)
@ -118,8 +109,54 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
return doc.label.toLowerCase().includes(searchQuery.toLowerCase());
});
// Filter connectors based on search query
const filteredConnectors = connectors.filter((connector) => {
// Get OAuth connector types set for quick lookup
const oauthConnectorTypes = new Set<string>(OAUTH_CONNECTORS.map((c) => c.connectorType));
// Separate OAuth and non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type));
// Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce(
(acc, connector) => {
const type = connector.connector_type;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(connector);
return acc;
},
{} as Record<string, SearchSourceConnector[]>
);
// Get display info for OAuth connector type
const getOAuthConnectorTypeInfo = (connectorType: string) => {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType);
return {
title:
oauthConnector?.title ||
connectorType
.replace(/_/g, " ")
.replace(/connector/gi, "")
.trim(),
};
};
// Filter OAuth connector types based on search query
const filteredOAuthConnectorTypes = Object.entries(oauthConnectorsByType).filter(
([connectorType]) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
const { title } = getOAuthConnectorTypeInfo(connectorType);
return (
title.toLowerCase().includes(searchLower) ||
connectorType.toLowerCase().includes(searchLower)
);
}
);
// Filter non-OAuth connectors based on search query
const filteredNonOAuthConnectors = nonOauthConnectors.filter((connector) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
@ -128,18 +165,97 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
);
});
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
return (
<TabsContent value="active" className="m-0">
{hasSources ? (
<div className="space-y-6">
{/* Active Connectors Section */}
{filteredConnectors.length > 0 && (
{hasActiveConnectors && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">Active Connectors</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredConnectors.map((connector) => {
{/* OAuth Connectors - Grouped by Type */}
{filteredOAuthConnectorTypes.map(([connectorType, typeConnectors]) => {
const { title } = getOAuthConnectorTypeInfo(connectorType);
const isAnyIndexing = typeConnectors.some((c: SearchSourceConnector) =>
indexingConnectorIds.has(c.id)
);
const documentCount = getDocumentCountForConnector(
connectorType,
documentTypeCounts
);
const accountCount = typeConnectors.length;
const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
const handleManageClick = () => {
if (onViewAccountsList) {
onViewAccountsList(connectorType, title);
} else if (onManage && typeConnectors[0]) {
onManage(typeConnectors[0]);
}
};
return (
<div
key={`oauth-type-${connectorType}`}
className={cn(
"relative flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isAnyIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isAnyIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon(connectorType, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
{isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{mostRecentLastIndexed
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
: "Never indexed"}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
</p>
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={handleManageClick}
>
Manage
</Button>
</div>
);
})}
{/* Non-OAuth Connectors - Individual Cards */}
{filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
@ -161,7 +277,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border",
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
@ -197,7 +313,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={onManage ? () => onManage(connector) : undefined}
>
Manage

View file

@ -1,12 +1,27 @@
"use client";
import { Plus } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { ConnectorCard } from "../components/connector-card";
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
/**
* Extract the display name from a full connector name.
* Full names are in format "Base Name - identifier" (e.g., "Gmail - john@example.com").
* Returns just the identifier (e.g : john@example.com).
*/
export function getConnectorDisplayName(fullName: string): string {
const separatorIndex = fullName.indexOf(" - ");
if (separatorIndex !== -1) {
return fullName.substring(separatorIndex + 3);
}
return fullName;
}
interface AllConnectorsTabProps {
searchQuery: string;
searchSpaceId: string;
@ -21,6 +36,7 @@ interface AllConnectorsTabProps {
onCreateWebcrawler?: () => void;
onCreateYouTubeCrawler?: () => void;
onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
@ -37,6 +53,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onCreateWebcrawler,
onCreateYouTubeCrawler,
onManage,
onViewAccountsList,
}) => {
// Helper to find active task for a connector
const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => {
@ -77,22 +94,39 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
{filteredOAuth.map((connector) => {
const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id;
// Find the actual connector object if connected
const actualConnector =
// Find all connectors of this type
const typeConnectors =
isConnected && allConnectors
? allConnectors.find(
? allConnectors.filter(
(c: SearchSourceConnector) => c.connector_type === connector.connectorType
)
: undefined;
: [];
// Get the most recent last_indexed_at across all accounts
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
(latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest)
? c.last_indexed_at
: latest;
},
undefined
);
const documentCount = getDocumentCountForConnector(
connector.connectorType,
documentTypeCounts
);
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
const activeTask = actualConnector
? getActiveTaskForConnector(actualConnector.id)
: undefined;
// Check if any account is currently indexing
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
// Get active task from any indexing account
const activeTask = typeConnectors
.map((c) => getActiveTaskForConnector(c.id))
.find((task) => task !== undefined);
return (
<ConnectorCard
@ -104,12 +138,15 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
lastIndexedAt={actualConnector?.last_indexed_at}
accountCount={typeConnectors.length}
lastIndexedAt={mostRecentLastIndexed}
isIndexing={isIndexing}
activeTask={activeTask}
onConnect={() => onConnectOAuth(connector)}
onManage={
actualConnector && onManage ? () => onManage(actualConnector) : undefined
isConnected && onViewAccountsList
? () => onViewAccountsList(connector.connectorType, connector.title)
: undefined
}
/>
);

View file

@ -11,6 +11,7 @@
export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
// Direct mappings (connector type matches document type)
SLACK_CONNECTOR: "SLACK_CONNECTOR",
TEAMS_CONNECTOR: "TEAMS_CONNECTOR",
NOTION_CONNECTOR: "NOTION_CONNECTOR",
GITHUB_CONNECTOR: "GITHUB_CONNECTOR",
LINEAR_CONNECTOR: "LINEAR_CONNECTOR",

View file

@ -0,0 +1,189 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowLeft, Loader2, Plus } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps {
connectorType: string;
connectorTitle: string;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void;
isConnecting?: boolean;
}
/**
* Format last indexed date with contextual messages
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
if (minutesAgo < 1) {
return "Just now";
}
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
return format(date, "MMM d, yyyy");
}
export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectorType,
connectorTitle,
connectors,
indexingConnectorIds,
logsSummary,
onBack,
onManage,
onAddAccount,
isConnecting = false,
}) => {
// Filter connectors to only show those of this type
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 sm:px-12 pt-6 sm:pt-10 pb-4 border-b border-border/50 bg-muted">
<div className="flex items-center justify-between gap-4 sm:pr-4">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="size-8 rounded-full shrink-0"
onClick={onBack}
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{getConnectorIcon(connectorType, "size-5")}
</div>
<div>
<h2 className="text-lg font-semibold">{connectorTitle} Accounts</h2>
<p className="text-xs text-muted-foreground">
{typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
{/* Add Account Button with dashed border */}
<button
type="button"
onClick={onAddAccount}
disabled={isConnecting}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg mr-4 border-2 border-dashed border-border/70 text-left transition-all duration-200",
"border-primary/50 hover:bg-primary/5",
isConnecting && "opacity-50 cursor-not-allowed"
)}
>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? (
<Loader2 className="size-3.5 animate-spin text-primary" />
) : (
<Plus className="size-3.5 text-primary" />
)}
</div>
<span className="text-[12px] font-medium">
{isConnecting ? "Connecting..." : "Add Account"}
</span>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 sm:px-12 py-6 sm:py-8">
{/* Connected Accounts Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
return (
<div
key={connector.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon(connector.connector_type, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{getConnectorDisplayName(connector.name)}
</p>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
{activeTask?.message && (
<span className="text-muted-foreground truncate max-w-[100px]">
{activeTask.message}
</span>
)}
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
</p>
)}
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
</div>
);
})}
</div>
</div>
</div>
);
};

View file

@ -1,7 +1,7 @@
"use client";
import { Upload } from "lucide-react";
import { useAtomValue } from "jotai";
import { Upload } from "lucide-react";
import { useRouter } from "next/navigation";
import {
createContext,

View file

@ -1,38 +0,0 @@
"use client";
import { Copy, CopyCheck } from "lucide-react";
import type { RefObject } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "./ui/button";
export default function CopyButton({ ref }: { ref: RefObject<HTMLDivElement | null> }) {
const [copy, setCopy] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleClick = () => {
if (ref.current) {
const text = ref.current.innerText;
navigator.clipboard.writeText(text);
setCopy(true);
timeoutRef.current = setTimeout(() => {
setCopy(false);
}, 2000);
}
};
return (
<div className="w-full flex justify-end">
<Button variant="ghost" onClick={handleClick}>
{copy ? <CopyCheck /> : <Copy />}
</Button>
</div>
);
}

View file

@ -1,22 +0,0 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
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>
);
}

View file

@ -1,28 +0,0 @@
"use client";
import type { Control } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel, 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>
)}
/>
);
}

View file

@ -1,189 +0,0 @@
import { CircleAlert, Edit, KeyRound, Loader2 } from "lucide-react";
import type React from "react";
import type { UseFormReturn } from "react-hook-form";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
// 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>
);
}

View file

@ -1,49 +0,0 @@
"use client";
import { KeyRound } from "lucide-react";
import type { Control } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
// 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>
)}
/>
);
}

View file

@ -1,59 +0,0 @@
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(),
TAVILY_API_KEY: z.string().optional(),
SEARXNG_HOST: z.string().optional(),
SEARXNG_API_KEY: z.string().optional(),
SEARXNG_ENGINES: z.string().optional(),
SEARXNG_CATEGORIES: z.string().optional(),
SEARXNG_LANGUAGE: z.string().optional(),
SEARXNG_SAFESEARCH: z.string().optional(),
SEARXNG_VERIFY_SSL: 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(),
BOOKSTACK_BASE_URL: z.string().optional(),
BOOKSTACK_TOKEN_ID: z.string().optional(),
BOOKSTACK_TOKEN_SECRET: z.string().optional(),
JIRA_BASE_URL: z.string().optional(),
JIRA_EMAIL: z.string().optional(),
JIRA_API_TOKEN: z.string().optional(),
GOOGLE_CALENDAR_CLIENT_ID: z.string().optional(),
GOOGLE_CALENDAR_CLIENT_SECRET: z.string().optional(),
GOOGLE_CALENDAR_REFRESH_TOKEN: z.string().optional(),
GOOGLE_CALENDAR_CALENDAR_IDS: z.string().optional(),
LUMA_API_KEY: z.string().optional(),
ELASTICSEARCH_API_KEY: z.string().optional(),
FIRECRAWL_API_KEY: z.string().optional(),
INITIAL_URLS: z.string().optional(),
});
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;

View file

@ -4,8 +4,31 @@ import Image from "next/image";
import Link from "next/link";
import React, { useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
// Official Google "G" logo with brand colors
const GoogleLogo = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
@ -60,7 +83,7 @@ export function HeroSection() {
<h2 className="relative z-50 mx-auto mb-4 mt-4 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
<Balancer>
The AI Workspace{" "}
<div className="relative mx-auto inline-block w-max [filter:drop-shadow(0px_1px_3px_rgba(27,_37,_80,_0.14))]">
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<span className="">Built for Teams</span>
</div>
@ -73,12 +96,7 @@ export function HeroSection() {
your team.
</p>
<div className="mb-10 mt-8 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-20">
<Link
href="/login"
className="group relative z-20 flex h-10 w-full cursor-pointer items-center justify-center space-x-2 rounded-lg bg-black p-px px-4 py-2 text-center text-sm font-semibold leading-6 text-white no-underline transition duration-200 sm:w-52 dark:bg-white dark:text-black"
>
Get Started
</Link>
<GetStartedButton />
{/* <Link
href="/pricing"
className="shadow-input group relative z-20 flex h-10 w-full cursor-pointer items-center justify-center space-x-2 rounded-lg bg-white p-px px-4 py-2 text-sm font-semibold leading-6 text-black no-underline transition duration-200 hover:-translate-y-0.5 sm:w-52 dark:bg-neutral-800 dark:text-white"
@ -115,6 +133,65 @@ export function HeroSection() {
);
}
function GetStartedButton() {
const isGoogleAuth = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE === "GOOGLE";
const handleGoogleLogin = () => {
trackLoginAttempt("google");
window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize-redirect`;
};
if (isGoogleAuth) {
return (
<motion.button
type="button"
onClick={handleGoogleLogin}
whileHover="hover"
whileTap={{ scale: 0.98 }}
initial="idle"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-3 overflow-hidden rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
variants={{
idle: { scale: 1, y: 0 },
hover: { scale: 1.02, y: -2 },
}}
>
{/* Animated gradient background on hover */}
<motion.div
className="absolute inset-0 bg-linear-to-r from-blue-50 via-green-50 to-yellow-50 dark:from-blue-950/30 dark:via-green-950/30 dark:to-yellow-950/30"
variants={{
idle: { opacity: 0 },
hover: { opacity: 1 },
}}
transition={{ duration: 0.3 }}
/>
{/* Google logo with subtle animation */}
<motion.div
className="relative"
variants={{
idle: { rotate: 0 },
hover: { rotate: [0, -8, 8, 0] },
}}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<GoogleLogo className="h-5 w-5" />
</motion.div>
<span className="relative">Continue with Google</span>
</motion.button>
);
}
return (
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
<Link
href="/login"
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
>
Get Started
</Link>
</motion.div>
);
}
const BackgroundGrids = () => {
return (
<div className="pointer-events-none absolute inset-0 z-0 grid h-full w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
@ -126,7 +203,7 @@ const BackgroundGrids = () => {
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full bg-gradient-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
<div className="relative h-full w-full bg-linear-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
@ -237,7 +314,7 @@ const CollisionMechanism = React.forwardRef<
repeatDelay: beamOptions.repeatDelay || 0,
}}
className={cn(
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-gradient-to-t from-orange-500 via-yellow-500 to-transparent",
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent",
beamOptions.className
)}
/>
@ -276,7 +353,7 @@ const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
animate={{ opacity: [0, 1, 0] }}
exit={{ opacity: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-gradient-to-r from-transparent via-orange-500 to-transparent blur-sm"
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-linear-to-r from-transparent via-orange-500 to-transparent blur-sm"
></motion.div>
{spans.map((span) => (
<motion.span
@ -284,7 +361,7 @@ const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
transition={{ duration: Math.random() * 1.5 + 0.5, ease: "easeOut" }}
className="absolute h-1 w-1 rounded-full bg-gradient-to-b from-orange-500 to-yellow-500"
className="absolute h-1 w-1 rounded-full bg-linear-to-b from-orange-500 to-yellow-500"
/>
))}
</div>
@ -307,11 +384,11 @@ const GridLineVertical = ({ className, offset }: { className?: string; offset?:
} as React.CSSProperties
}
className={cn(
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-[var(--width)]",
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-(--width)",
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"bg-size-[var(--width)_var(--height)]",
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),linear-gradient(black,black)]",
"mask-exclude",
"z-30",
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className

View file

@ -54,7 +54,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
: "bg-transparent border border-transparent"
)}
>
<div className="flex flex-1 flex-row items-center gap-2">
<div className="flex flex-1 flex-row items-center gap-0.5">
<Logo className="h-8 w-8 rounded-md" />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</div>

View file

@ -1,5 +1,5 @@
import Image from "next/image";
import { type StreamdownProps, Streamdown } from "streamdown";
import { Streamdown, type StreamdownProps } from "streamdown";
import { cn } from "@/lib/utils";
interface MarkdownViewerProps {

View file

@ -1,15 +1,15 @@
"use client";
import { useAtomValue } from "jotai";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { fetchThreads } from "@/lib/chat/thread-persistence";
interface TourStep {

View file

@ -45,7 +45,7 @@ const ROLE_DESCRIPTIONS = {
document_summary: {
icon: FileText,
title: "Document Summary LLM",
description: "Handles document summarization, long context analysis, and query reformulation",
description: "Handles document summarization",
color: "bg-purple-100 text-purple-800 border-purple-200",
examples: "Document analysis, podcasts, research synthesis",
characteristics: ["Large context window", "Deep reasoning", "Summarization"],
@ -74,7 +74,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
data: preferences = {},
isFetching: preferencesLoading,
error: preferencesError,
refetch: refreshPreferences,
} = useAtomValue(llmPreferencesAtom);
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
@ -187,19 +186,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
<span className="hidden sm:inline">Refresh Configs</span>
<span className="sm:hidden">Configs</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => refreshPreferences()}
disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
>
<RefreshCw
className={`h-3 w-3 md:h-4 md:w-4 ${preferencesLoading ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">Refresh Preferences</span>
<span className="sm:hidden">Prefs</span>
</Button>
</div>
</div>

View file

@ -160,7 +160,7 @@ const defaultData = {
user: {
name: "Surf",
email: "m@example.com",
avatar: "/icon-128.png",
avatar: "/icon-128.svg",
},
navMain: [
{

View file

@ -1,13 +0,0 @@
export interface Connector {
id: string;
title: string;
description: string;
icon: React.ReactNode;
status: "available" | "coming-soon" | "connected";
}
export interface ConnectorCategory {
id: string;
title: string;
connectors: Connector[];
}

View file

@ -1,41 +0,0 @@
"use client";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import type { Action, ActionsConfig } from "./schema";
interface ActionButtonsProps {
actions?: Action[] | ActionsConfig;
onAction?: (actionId: string) => void;
disabled?: boolean;
}
export const ActionButtons: FC<ActionButtonsProps> = ({ actions, onAction, disabled }) => {
if (!actions) return null;
// Normalize actions to array format
const actionArray: Action[] = Array.isArray(actions)
? actions
: ([
actions.confirm && { ...actions.confirm, id: "confirm" },
actions.cancel && { ...actions.cancel, id: "cancel" },
].filter(Boolean) as Action[]);
if (actionArray.length === 0) return null;
return (
<div className="flex flex-wrap gap-2 pt-3">
{actionArray.map((action) => (
<Button
key={action.id}
variant={action.variant || "default"}
size="sm"
disabled={disabled || action.disabled}
onClick={() => onAction?.(action.id)}
>
{action.label}
</Button>
))}
</div>
);
};

View file

@ -1,2 +0,0 @@
export * from "./action-buttons";
export * from "./schema";

View file

@ -1,23 +0,0 @@
import { z } from "zod";
/**
* Shared action schema for tool UI components
*/
export const ActionSchema = z.object({
id: z.string(),
label: z.string(),
variant: z.enum(["default", "secondary", "destructive", "outline", "ghost", "link"]).optional(),
disabled: z.boolean().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
/**
* Actions configuration schema
*/
export const ActionsConfigSchema = z.object({
confirm: ActionSchema.optional(),
cancel: ActionSchema.optional(),
});
export type ActionsConfig = z.infer<typeof ActionsConfigSchema>;

View file

@ -3,4 +3,99 @@ title: Airtable
description: Connect your Airtable bases to SurfSense
---
# Documentation in progress
# Airtable OAuth Integration Setup Guide
This guide walks you through setting up an Airtable OAuth integration for SurfSense.
## Step 1: Access Airtable OAuth Integrations
1. Navigate to [airtable.com/create/oauth](https://airtable.com/create/oauth)
2. In the **Builder Hub**, under **Developers**, click **"OAuth integrations"**
3. Click **"Register an OAuth integration"**
![Airtable OAuth Integrations Page](/docs/connectors/airtable/airtable-oauth-integrations.png)
## Step 2: Register an Integration
Fill in the basic integration details:
| Field | Value |
|-------|-------|
| **Name** | `SurfSense` |
| **OAuth redirect URL** | `http://localhost:8000/api/v1/auth/airtable/connector/callback` |
Click **"Register integration"**
![Register Integration Form](/docs/connectors/airtable/airtable-register-integration.png)
## Step 3: Configure Scopes
After registration, configure the required scopes (permissions) for your integration:
### Record data and comments
| Scope | Description |
|-------|-------------|
| ✅ `data.recordComments:read` | See comments in records |
| ✅ `data.records:read` | See the data in records |
### Base schema
| Scope | Description |
|-------|-------------|
| ✅ `schema.bases:read` | See the structure of a base, like table names or field types |
### User metadata
| Scope | Description |
|-------|-------------|
| ✅ `user.email:read` | See the user's email address |
![Scopes Configuration](/docs/connectors/airtable/airtable-scopes.png)
## Step 4: Configure Support Information
Scroll down to configure the support information and authorization preview:
| Field | Value |
|-------|-------|
| **Support email** | Your support email address |
| **Privacy policy URL** | Your privacy policy URL |
| **Terms of service URL** | Your terms of service URL |
The preview shows what users will see when authorizing SurfSense:
- The data in your records
- Comments in your records
- The structure of your base, like table names or field types
- Your email address
Click **"Save changes"**
![Support Information & Preview](/docs/connectors/airtable/airtable-support-info.png)
## Step 5: Get OAuth Credentials
After saving, you'll find your OAuth credentials on the integration page:
1. Copy your **Client ID**
2. Copy your **Client Secret**
> ⚠️ Never share your client secret publicly.
---
## Running SurfSense with Airtable Connector
Add the Airtable environment variables to your Docker run command:
```bash
docker run -d -p 3000:3000 -p 8000:8000 \
-v surfsense-data:/data \
# Airtable Connector
-e AIRTABLE_CLIENT_ID=your_airtable_client_id \
-e AIRTABLE_CLIENT_SECRET=your_airtable_client_secret \
-e AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```

View file

@ -3,4 +3,55 @@ title: ClickUp
description: Connect your ClickUp workspace to SurfSense
---
# Documentation in progress
# ClickUp OAuth Integration Setup Guide
This guide walks you through setting up a ClickUp OAuth integration for SurfSense.
## Step 1: Access ClickUp API Settings
1. Open your ClickUp workspace
2. Navigate to **Settings** (gear icon) → **ClickUp API**
3. You'll see the **ClickUp API Settings** page
![ClickUp API Settings Page](/docs/connectors/clickup/clickup-api-settings.png)
## Step 2: Create an App
1. Click **"+ Create an App"** in the top-right corner
2. Fill in the app details:
| Field | Value |
|-------|-------|
| **App Name** | `SurfSense` |
| **Redirect URL(s)** | `localhost:8000` |
3. Click **"Save"** to create the app
![App Created with Credentials](/docs/connectors/clickup/clickup-app-credentials.png)
## Step 3: Get OAuth Credentials
After creating the app, you'll see your credentials:
1. Copy your **Client ID**
2. Copy your **Client Secret** (click "Show" to reveal, or "Regenerate" if needed)
> ⚠️ Never share your client secret publicly.
---
## Running SurfSense with ClickUp Connector
Add the ClickUp environment variables to your Docker run command:
```bash
docker run -d -p 3000:3000 -p 8000:8000 \
-v surfsense-data:/data \
# ClickUp Connector
-e CLICKUP_CLIENT_ID=your_clickup_client_id \
-e CLICKUP_CLIENT_SECRET=your_clickup_client_secret \
-e CLICKUP_REDIRECT_URI=http://localhost:8000/api/v1/auth/clickup/connector/callback \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```

View file

@ -85,7 +85,7 @@ Select the **"Granular scopes"** tab and enable:
1. In the left sidebar, click **"Settings"**
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly or include it in code repositories.
> ⚠️ Never share your client secret publicly.
---

View file

@ -60,7 +60,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly or include it in code repositories.
> ⚠️ Never share your client secret publicly.
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png)

View file

@ -59,7 +59,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly or include it in code repositories.
> ⚠️ Never share your client secret publicly.
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png)

View file

@ -60,7 +60,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
1. After creating the OAuth client, you'll see a dialog with your credentials
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly or include it in code repositories.
> ⚠️ Never share your client secret publicly.
![Google Developer Console Config](/docs/connectors/google/google_oauth_config.png)

View file

@ -72,7 +72,7 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
1. In the left sidebar, click **"Settings"**
2. Copy your **Client ID** and **Client Secret**
> ⚠️ Never share your client secret publicly or include it in code repositories.
> ⚠️ Never share your client secret publicly.
---

View file

@ -9,6 +9,7 @@
"discord",
"jira",
"linear",
"microsoft-teams",
"confluence",
"airtable",
"clickup",
@ -20,4 +21,3 @@
],
"defaultOpen": true
}

View file

@ -0,0 +1,101 @@
---
title: Microsoft Teams
description: Connect your Microsoft Teams to SurfSense
---
# Microsoft Teams OAuth Integration Setup Guide
This guide walks you through setting up a Microsoft Teams OAuth integration for SurfSense using Azure App Registration.
## Step 1: Access Azure App Registrations
1. Navigate to [portal.azure.com](https://portal.azure.com)
2. In the search bar, type **"app reg"**
3. Select **"App registrations"** from the Services results
![Azure Portal Search](/docs/connectors/microsoft-teams/azure-search-app-reg.png)
## Step 2: Create New Registration
1. On the **App registrations** page, click **"+ New registration"**
![App Registrations Page](/docs/connectors/microsoft-teams/azure-app-registrations.png)
## Step 3: Register the Application
Fill in the application details:
| Field | Value |
|-------|-------|
| **Name** | `SurfSense` |
| **Supported account types** | Select **"Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts"** |
| **Redirect URI** | Platform: `Web`, URI: `http://localhost:8000/api/v1/auth/teams/connector/callback` |
Click **"Register"**
![Register Application Form](/docs/connectors/microsoft-teams/azure-register-app.png)
## Step 4: Get Application (Client) ID
After registration, you'll be taken to the app's **Overview** page. Here you'll find:
1. Copy the **Application (client) ID** - this is your Client ID
2. Note the **Directory (tenant) ID** if needed
![Application Overview](/docs/connectors/microsoft-teams/azure-app-overview.png)
## Step 5: Create Client Secret
1. In the left sidebar under **Manage**, click **"Certificates & secrets"**
2. Select the **"Client secrets"** tab
3. Click **"+ New client secret"**
4. Enter a description (e.g., `SurfSense`) and select an expiration period
5. Click **"Add"**
![Certificates & Secrets - Empty](/docs/connectors/microsoft-teams/azure-certificates-empty.png)
6. **Important**: Copy the secret **Value** immediately - it won't be shown again!
![Certificates & Secrets - Created](/docs/connectors/microsoft-teams/azure-certificates-created.png)
> ⚠️ Never share your client secret publicly or include it in code repositories.
## Step 6: Configure API Permissions
1. In the left sidebar under **Manage**, click **"API permissions"**
2. Click **"+ Add a permission"**
3. Select **"Microsoft Graph"**
4. Select **"Delegated permissions"**
5. Add the following permissions:
| Permission | Type | Description | Admin Consent |
|------------|------|-------------|---------------|
| `Channel.ReadBasic.All` | Delegated | Read the names and descriptions of channels | No |
| `ChannelMessage.Read.All` | Delegated | Read user channel messages | Yes |
| `offline_access` | Delegated | Maintain access to data you have given it access to | No |
| `Team.ReadBasic.All` | Delegated | Read the names and descriptions of teams | No |
| `User.Read` | Delegated | Sign in and read user profile | No |
6. Click **"Add permissions"**
> ⚠️ The `ChannelMessage.Read.All` permission requires admin consent. An admin will need to click **"Grant admin consent for [Directory]"** for full functionality.
![API Permissions](/docs/connectors/microsoft-teams/azure-api-permissions.png)
---
## Running SurfSense with Microsoft Teams Connector
Add the Microsoft Teams environment variables to your Docker run command:
```bash
docker run -d -p 3000:3000 -p 8000:8000 \
-v surfsense-data:/data \
# Microsoft Teams Connector
-e TEAMS_CLIENT_ID=your_microsoft_client_id \
-e TEAMS_CLIENT_SECRET=your_microsoft_client_secret \
-e TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```

View file

@ -32,7 +32,7 @@ After creating the app, you'll be taken to the **Basic Information** page. Here
1. Copy your **Client ID**
2. Copy your **Client Secret** (click Show to reveal)
> ⚠️ Never share your app credentials publicly or include them in code repositories.
> ⚠️ Never share your app credentials publicly.
![Basic Information - App Credentials](/docs/connectors/slack/slack-app-credentials.png)

View file

@ -47,31 +47,29 @@ docker run -d -p 3000:3000 -p 8000:8000 `
### With Custom Configuration
**Using OpenAI Embeddings:**
You can pass any [environment variable](/docs/manual-installation#backend-environment-variables) using `-e` flags:
```bash
docker run -d -p 3000:3000 -p 8000:8000 \
-v surfsense-data:/data \
-e EMBEDDING_MODEL=openai://text-embedding-ada-002 \
-e OPENAI_API_KEY=your_openai_api_key \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
**With Google OAuth:**
```bash
docker run -d -p 3000:3000 -p 8000:8000 \
-v surfsense-data:/data \
-e AUTH_TYPE=GOOGLE \
-e GOOGLE_OAUTH_CLIENT_ID=your_client_id \
-e GOOGLE_OAUTH_CLIENT_SECRET=your_client_secret \
-e GOOGLE_OAUTH_CLIENT_ID=your_google_client_id \
-e GOOGLE_OAUTH_CLIENT_SECRET=your_google_client_secret \
-e ETL_SERVICE=LLAMACLOUD \
-e LLAMA_CLOUD_API_KEY=your_llama_cloud_key \
--name surfsense \
--restart unless-stopped \
ghcr.io/modsetter/surfsense:latest
```
<Callout type="info">
- For Google OAuth, create credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
- For Airtable connector, create an OAuth integration in the [Airtable Developer Hub](https://airtable.com/create/oauth)
- If deploying behind a reverse proxy with HTTPS, add `-e BACKEND_URL=https://api.yourdomain.com`
</Callout>
### Quick Start with Docker Compose
For easier management with environment files:

View file

@ -233,7 +233,7 @@ redis-cli ping
In a new terminal window, start the Celery worker to handle background tasks:
**Linux/macOS/Windows:**
**If using uv:**
```bash
# Make sure you're in the surfsense_backend directory
@ -243,13 +243,31 @@ cd surfsense_backend
uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo
```
**If using pip/venv:**
```bash
# Make sure you're in the surfsense_backend directory
cd surfsense_backend
# Activate virtual environment
source .venv/bin/activate # Linux/macOS
# OR
.venv\Scripts\activate # Windows
# Start Celery worker
celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo
```
**Optional: Start Flower for monitoring Celery tasks:**
In another terminal window:
```bash
# Start Flower (Celery monitoring tool)
# If using uv
uv run celery -A celery_worker.celery_app flower --port=5555
# If using pip/venv (activate venv first)
celery -A celery_worker.celery_app flower --port=5555
```
Access Flower at [http://localhost:5555](http://localhost:5555) to monitor your Celery tasks.
@ -258,7 +276,7 @@ Access Flower at [http://localhost:5555](http://localhost:5555) to monitor your
In another new terminal window, start Celery Beat to enable periodic tasks (like scheduled connector indexing):
**Linux/macOS/Windows:**
**If using uv:**
```bash
# Make sure you're in the surfsense_backend directory
@ -268,13 +286,28 @@ cd surfsense_backend
uv run celery -A celery_worker.celery_app beat --loglevel=info
```
**If using pip/venv:**
```bash
# Make sure you're in the surfsense_backend directory
cd surfsense_backend
# Activate virtual environment
source .venv/bin/activate # Linux/macOS
# OR
.venv\Scripts\activate # Windows
# Start Celery Beat
celery -A celery_worker.celery_app beat --loglevel=info
```
**Important**: Celery Beat is required for the periodic indexing functionality to work. Without it, scheduled connector tasks won't run automatically. The schedule interval can be configured using the `SCHEDULE_CHECKER_INTERVAL` environment variable.
### 6. Run the Backend
Start the backend server:
**Linux/macOS/Windows:**
**If using uv:**
```bash
# Run without hot reloading
@ -284,6 +317,21 @@ uv run main.py
uv run main.py --reload
```
**If using pip/venv:**
```bash
# Activate virtual environment if not already activated
source .venv/bin/activate # Linux/macOS
# OR
.venv\Scripts\activate # Windows
# Run without hot reloading
python main.py
# Or with hot reloading for development
python main.py --reload
```
If everything is set up correctly, you should see output indicating the server is running on `http://localhost:8000`.
## Frontend Setup

View file

@ -4,6 +4,7 @@ export enum EnumConnectorName {
LINKUP_API = "LINKUP_API",
BAIDU_SEARCH_API = "BAIDU_SEARCH_API",
SLACK_CONNECTOR = "SLACK_CONNECTOR",
TEAMS_CONNECTOR = "TEAMS_CONNECTOR",
NOTION_CONNECTOR = "NOTION_CONNECTOR",
GITHUB_CONNECTOR = "GITHUB_CONNECTOR",
LINEAR_CONNECTOR = "LINEAR_CONNECTOR",

View file

@ -31,6 +31,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <Image src="/connectors/baidu-search.svg" alt="Baidu" {...imgProps} />;
case EnumConnectorName.SLACK_CONNECTOR:
return <Image src="/connectors/slack.svg" alt="Slack" {...imgProps} />;
case EnumConnectorName.TEAMS_CONNECTOR:
return <Image src="/connectors/microsoft-teams.svg" alt="Microsoft Teams" {...imgProps} />;
case EnumConnectorName.NOTION_CONNECTOR:
return <Image src="/connectors/notion.svg" alt="Notion" {...imgProps} />;
case EnumConnectorName.DISCORD_CONNECTOR:

View file

@ -8,6 +8,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
"LINKUP_API",
"BAIDU_SEARCH_API",
"SLACK_CONNECTOR",
"TEAMS_CONNECTOR",
"NOTION_CONNECTOR",
"GITHUB_CONNECTOR",
"LINEAR_CONNECTOR",

View file

@ -1,43 +0,0 @@
import { useEffect, useState } from "react";
import type { ResearchMode } from "@/components/chat";
import type { Document } from "@/contracts/types/document.types";
import { getBearerToken } from "@/lib/auth-utils";
interface UseChatStateProps {
search_space_id: string;
chat_id?: string;
}
export function useChatState({ chat_id }: UseChatStateProps) {
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [currentChatId, setCurrentChatId] = useState<string | null>(chat_id || null);
// Chat configuration state
const [researchMode, setResearchMode] = useState<ResearchMode>("QNA");
const [selectedConnectors, setSelectedConnectors] = useState<string[]>([]);
const [selectedDocuments, setSelectedDocuments] = useState<Document[]>([]);
const [topK, setTopK] = useState<number>(5);
useEffect(() => {
const bearerToken = getBearerToken();
setToken(bearerToken);
}, []);
return {
token,
setToken,
isLoading,
setIsLoading,
currentChatId,
setCurrentChatId,
researchMode,
setResearchMode,
selectedConnectors,
setSelectedConnectors,
selectedDocuments,
setSelectedDocuments,
topK,
setTopK,
};
}

View file

@ -1,680 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import {
type EditConnectorFormValues,
type EditMode,
editConnectorSchema,
type GithubPatFormValues,
type GithubRepo,
githubPatSchema,
} from "@/components/editConnector/types";
import type { EnumConnectorName } from "@/contracts/enums/connector";
import type { UpdateConnectorResponse } from "@/contracts/types/connector.types";
import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors";
import { authenticatedFetch } from "@/lib/auth-utils";
const normalizeListInput = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter((item) => item.length > 0);
}
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
return [];
};
const arraysEqual = (a: string[], b: string[]): boolean => {
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
};
const normalizeBoolean = (value: unknown): boolean | null => {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const lowered = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(lowered)) return true;
if (["false", "0", "no", "off"].includes(lowered)) return false;
}
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
}
return null;
};
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
const router = useRouter();
const { data: connectors = [], isLoading: connectorsLoading } = useAtomValue(connectorsAtom);
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
// State managed by the hook
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [originalConfig, setOriginalConfig] = useState<Record<string, unknown> | 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: "",
TAVILY_API_KEY: "",
SEARXNG_HOST: "",
SEARXNG_API_KEY: "",
SEARXNG_ENGINES: "",
SEARXNG_CATEGORIES: "",
SEARXNG_LANGUAGE: "",
SEARXNG_SAFESEARCH: "",
SEARXNG_VERIFY_SSL: "",
DISCORD_BOT_TOKEN: "",
CONFLUENCE_BASE_URL: "",
CONFLUENCE_EMAIL: "",
CONFLUENCE_API_TOKEN: "",
BOOKSTACK_BASE_URL: "",
BOOKSTACK_TOKEN_ID: "",
BOOKSTACK_TOKEN_SECRET: "",
JIRA_BASE_URL: "",
JIRA_EMAIL: "",
JIRA_API_TOKEN: "",
LUMA_API_KEY: "",
ELASTICSEARCH_API_KEY: "",
FIRECRAWL_API_KEY: "",
INITIAL_URLS: "",
},
});
// 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 || "",
TAVILY_API_KEY: config.TAVILY_API_KEY || "",
SEARXNG_HOST: config.SEARXNG_HOST || "",
SEARXNG_API_KEY: config.SEARXNG_API_KEY || "",
SEARXNG_ENGINES: Array.isArray(config.SEARXNG_ENGINES)
? config.SEARXNG_ENGINES.join(", ")
: config.SEARXNG_ENGINES || "",
SEARXNG_CATEGORIES: Array.isArray(config.SEARXNG_CATEGORIES)
? config.SEARXNG_CATEGORIES.join(", ")
: config.SEARXNG_CATEGORIES || "",
SEARXNG_LANGUAGE: config.SEARXNG_LANGUAGE || "",
SEARXNG_SAFESEARCH:
config.SEARXNG_SAFESEARCH !== undefined && config.SEARXNG_SAFESEARCH !== null
? String(config.SEARXNG_SAFESEARCH)
: "",
SEARXNG_VERIFY_SSL:
config.SEARXNG_VERIFY_SSL !== undefined && config.SEARXNG_VERIFY_SSL !== null
? String(config.SEARXNG_VERIFY_SSL)
: "",
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 || "",
BOOKSTACK_BASE_URL: config.BOOKSTACK_BASE_URL || "",
BOOKSTACK_TOKEN_ID: config.BOOKSTACK_TOKEN_ID || "",
BOOKSTACK_TOKEN_SECRET: config.BOOKSTACK_TOKEN_SECRET || "",
JIRA_BASE_URL: config.JIRA_BASE_URL || "",
JIRA_EMAIL: config.JIRA_EMAIL || "",
JIRA_API_TOKEN: config.JIRA_API_TOKEN || "",
LUMA_API_KEY: config.LUMA_API_KEY || "",
ELASTICSEARCH_API_KEY: config.ELASTICSEARCH_API_KEY || "",
FIRECRAWL_API_KEY: config.FIRECRAWL_API_KEY || "",
INITIAL_URLS: config.INITIAL_URLS || "",
});
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}`);
}
}
}, [
connectorId,
connectors,
connectorsLoading,
router,
searchSpaceId,
connector,
editForm.reset,
patForm.reset,
// Note: editForm and patForm are intentionally excluded from dependencies
// to prevent infinite loops. They are stable form objects from react-hook-form.
]);
// Handlers managed by the hook
const handleFetchRepositories = useCallback(
async (values: GithubPatFormValues) => {
setIsFetchingRepos(true);
setFetchedRepos(null);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
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, unknown> | 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 "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 "SEARXNG_API": {
const host = (formData.SEARXNG_HOST || "").trim();
if (!host) {
toast.error("SearxNG host is required.");
setIsSaving(false);
return;
}
const candidateConfig: Record<string, unknown> = { SEARXNG_HOST: host };
const originalHost =
typeof originalConfig.SEARXNG_HOST === "string" ? originalConfig.SEARXNG_HOST : "";
let hasChanges = host !== originalHost.trim();
const apiKey = (formData.SEARXNG_API_KEY || "").trim();
const originalApiKey =
typeof originalConfig.SEARXNG_API_KEY === "string"
? originalConfig.SEARXNG_API_KEY
: "";
const originalApiKeyTrimmed = originalApiKey.trim();
if (apiKey !== originalApiKeyTrimmed) {
candidateConfig.SEARXNG_API_KEY = apiKey || null;
hasChanges = true;
}
const newEngines = normalizeListInput(formData.SEARXNG_ENGINES || "");
const originalEngines = normalizeListInput(originalConfig.SEARXNG_ENGINES);
if (!arraysEqual(newEngines, originalEngines)) {
candidateConfig.SEARXNG_ENGINES = newEngines;
hasChanges = true;
}
const newCategories = normalizeListInput(formData.SEARXNG_CATEGORIES || "");
const originalCategories = normalizeListInput(originalConfig.SEARXNG_CATEGORIES);
if (!arraysEqual(newCategories, originalCategories)) {
candidateConfig.SEARXNG_CATEGORIES = newCategories;
hasChanges = true;
}
const language = (formData.SEARXNG_LANGUAGE || "").trim();
const originalLanguage =
typeof originalConfig.SEARXNG_LANGUAGE === "string"
? originalConfig.SEARXNG_LANGUAGE
: "";
const originalLanguageTrimmed = originalLanguage.trim();
if (language !== originalLanguageTrimmed) {
candidateConfig.SEARXNG_LANGUAGE = language || null;
hasChanges = true;
}
const safesearchRaw = (formData.SEARXNG_SAFESEARCH || "").trim();
const originalSafesearch = originalConfig.SEARXNG_SAFESEARCH;
if (safesearchRaw) {
const parsed = Number(safesearchRaw);
if (Number.isNaN(parsed) || !Number.isInteger(parsed) || parsed < 0 || parsed > 2) {
toast.error("SearxNG SafeSearch must be 0, 1, or 2.");
setIsSaving(false);
return;
}
if (parsed !== Number(originalSafesearch)) {
candidateConfig.SEARXNG_SAFESEARCH = parsed;
hasChanges = true;
}
} else if (originalSafesearch !== undefined && originalSafesearch !== null) {
candidateConfig.SEARXNG_SAFESEARCH = null;
hasChanges = true;
}
const verifyRaw = (formData.SEARXNG_VERIFY_SSL || "").trim().toLowerCase();
const originalVerifyBool = normalizeBoolean(originalConfig.SEARXNG_VERIFY_SSL);
if (verifyRaw) {
let parsedBool: boolean | null = null;
if (["true", "1", "yes", "on"].includes(verifyRaw)) parsedBool = true;
else if (["false", "0", "no", "off"].includes(verifyRaw)) parsedBool = false;
if (parsedBool === null) {
toast.error("SearxNG SSL verification must be true or false.");
setIsSaving(false);
return;
}
if (parsedBool !== originalVerifyBool) {
candidateConfig.SEARXNG_VERIFY_SSL = parsedBool;
hasChanges = true;
}
} else if (originalVerifyBool !== null) {
candidateConfig.SEARXNG_VERIFY_SSL = null;
hasChanges = true;
}
if (hasChanges) {
newConfig = candidateConfig;
}
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 "BOOKSTACK_CONNECTOR":
if (
formData.BOOKSTACK_BASE_URL !== originalConfig.BOOKSTACK_BASE_URL ||
formData.BOOKSTACK_TOKEN_ID !== originalConfig.BOOKSTACK_TOKEN_ID ||
formData.BOOKSTACK_TOKEN_SECRET !== originalConfig.BOOKSTACK_TOKEN_SECRET
) {
if (
!formData.BOOKSTACK_BASE_URL ||
!formData.BOOKSTACK_TOKEN_ID ||
!formData.BOOKSTACK_TOKEN_SECRET
) {
toast.error("All BookStack fields are required.");
setIsSaving(false);
return;
}
newConfig = {
BOOKSTACK_BASE_URL: formData.BOOKSTACK_BASE_URL,
BOOKSTACK_TOKEN_ID: formData.BOOKSTACK_TOKEN_ID,
BOOKSTACK_TOKEN_SECRET: formData.BOOKSTACK_TOKEN_SECRET,
};
}
break;
case "JIRA_CONNECTOR": {
// Check if this is an OAuth connector (has access_token or _token_encrypted flag)
const isJiraOAuth = !!(originalConfig.access_token || originalConfig._token_encrypted);
if (isJiraOAuth) {
// OAuth connectors don't allow editing credentials through the form
// Only allow name changes, which are handled separately
break;
}
// Legacy API token connector - allow editing credentials
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;
}
case "LUMA_CONNECTOR":
if (formData.LUMA_API_KEY !== originalConfig.LUMA_API_KEY) {
if (!formData.LUMA_API_KEY) {
toast.error("Luma API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { LUMA_API_KEY: formData.LUMA_API_KEY };
}
break;
case "ELASTICSEARCH_CONNECTOR":
if (formData.ELASTICSEARCH_API_KEY !== originalConfig.ELASTICSEARCH_API_KEY) {
if (!formData.ELASTICSEARCH_API_KEY) {
toast.error("Elasticsearch API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { ELASTICSEARCH_API_KEY: formData.ELASTICSEARCH_API_KEY };
}
break;
case "WEBCRAWLER_CONNECTOR":
if (
formData.FIRECRAWL_API_KEY !== originalConfig.FIRECRAWL_API_KEY ||
formData.INITIAL_URLS !== originalConfig.INITIAL_URLS
) {
newConfig = {};
if (formData.FIRECRAWL_API_KEY?.trim()) {
if (!formData.FIRECRAWL_API_KEY.startsWith("fc-")) {
toast.warning(
"Firecrawl API keys typically start with 'fc-'. Please verify your key."
);
}
newConfig.FIRECRAWL_API_KEY = formData.FIRECRAWL_API_KEY.trim();
} else if (originalConfig.FIRECRAWL_API_KEY) {
toast.info(
"Firecrawl API key removed. Web crawler will use AsyncChromiumLoader as fallback."
);
}
if (formData.INITIAL_URLS !== undefined) {
if (formData.INITIAL_URLS?.trim()) {
newConfig.INITIAL_URLS = formData.INITIAL_URLS.trim();
} else if (originalConfig.INITIAL_URLS) {
toast.info("URLs removed from crawler configuration.");
}
}
}
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 {
const updatedConnector = (await updateConnector({
id: connectorId,
data: {
...updatePayload,
connector_type: connector.connector_type as EnumConnectorName,
},
})) as UpdateConnectorResponse;
toast.success("Connector updated!");
// Use the response from the API which has the full merged config
const newlySavedConfig = updatedConnector.config || originalConfig;
setOriginalConfig(newlySavedConfig);
// Update connector state with the full updated connector from the API
setConnector(updatedConnector);
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 === "TAVILY_API") {
editForm.setValue("TAVILY_API_KEY", newlySavedConfig.TAVILY_API_KEY || "");
} else if (connector.connector_type === "SEARXNG_API") {
editForm.setValue("SEARXNG_HOST", newlySavedConfig.SEARXNG_HOST || "");
editForm.setValue("SEARXNG_API_KEY", newlySavedConfig.SEARXNG_API_KEY || "");
editForm.setValue(
"SEARXNG_ENGINES",
normalizeListInput(newlySavedConfig.SEARXNG_ENGINES).join(", ")
);
editForm.setValue(
"SEARXNG_CATEGORIES",
normalizeListInput(newlySavedConfig.SEARXNG_CATEGORIES).join(", ")
);
editForm.setValue("SEARXNG_LANGUAGE", newlySavedConfig.SEARXNG_LANGUAGE || "");
editForm.setValue(
"SEARXNG_SAFESEARCH",
newlySavedConfig.SEARXNG_SAFESEARCH === null ||
newlySavedConfig.SEARXNG_SAFESEARCH === undefined
? ""
: String(newlySavedConfig.SEARXNG_SAFESEARCH)
);
const verifyValue = normalizeBoolean(newlySavedConfig.SEARXNG_VERIFY_SSL);
editForm.setValue(
"SEARXNG_VERIFY_SSL",
verifyValue === null ? "" : String(verifyValue)
);
} 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 === "BOOKSTACK_CONNECTOR") {
editForm.setValue("BOOKSTACK_BASE_URL", newlySavedConfig.BOOKSTACK_BASE_URL || "");
editForm.setValue("BOOKSTACK_TOKEN_ID", newlySavedConfig.BOOKSTACK_TOKEN_ID || "");
editForm.setValue(
"BOOKSTACK_TOKEN_SECRET",
newlySavedConfig.BOOKSTACK_TOKEN_SECRET || ""
);
} 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 || "");
} else if (connector.connector_type === "LUMA_CONNECTOR") {
editForm.setValue("LUMA_API_KEY", newlySavedConfig.LUMA_API_KEY || "");
} else if (connector.connector_type === "ELASTICSEARCH_CONNECTOR") {
editForm.setValue(
"ELASTICSEARCH_API_KEY",
newlySavedConfig.ELASTICSEARCH_API_KEY || ""
);
} else if (connector.connector_type === "WEBCRAWLER_CONNECTOR") {
editForm.setValue("FIRECRAWL_API_KEY", newlySavedConfig.FIRECRAWL_API_KEY || "");
editForm.setValue("INITIAL_URLS", newlySavedConfig.INITIAL_URLS || "");
}
}
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,
};
}

View file

@ -130,44 +130,3 @@ export async function authenticatedFetch(
return response;
}
/**
* Type for the result of a fetch operation with built-in error handling
*/
export type FetchResult<T> =
| { success: true; data: T; response: Response }
| { success: false; error: string; status?: number };
/**
* Authenticated fetch with JSON response handling
* Returns a result object instead of throwing on non-401 errors
*/
export async function authenticatedFetchJson<T = unknown>(
url: string,
options?: RequestInit & { skipAuthRedirect?: boolean }
): Promise<FetchResult<T>> {
try {
const response = await authenticatedFetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.detail || `Request failed: ${response.status}`,
status: response.status,
};
}
const data = await response.json();
return { success: true, data, response };
} catch (err: any) {
// Re-throw if it's the unauthorized redirect
if (err.message?.includes("Unauthorized")) {
throw err;
}
return {
success: false,
error: err.message || "Request failed",
};
}
}

View file

@ -15,6 +15,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
CLICKUP_CONNECTOR: "ClickUp",
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
GOOGLE_GMAIL_CONNECTOR: "Google Gmail",
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
AIRTABLE_CONNECTOR: "Airtable",
LUMA_CONNECTOR: "Luma",
ELASTICSEARCH_CONNECTOR: "Elasticsearch",

View file

@ -271,6 +271,156 @@ export function trackSourcesTabViewed(searchSpaceId: number, tab: string) {
});
}
// ============================================
// SEARCH SPACE INVITE EVENTS
// ============================================
export function trackSearchSpaceInviteSent(
searchSpaceId: number,
options?: {
roleName?: string;
hasExpiry?: boolean;
hasMaxUses?: boolean;
}
) {
posthog.capture("search_space_invite_sent", {
search_space_id: searchSpaceId,
role_name: options?.roleName,
has_expiry: options?.hasExpiry ?? false,
has_max_uses: options?.hasMaxUses ?? false,
});
}
export function trackSearchSpaceInviteAccepted(
searchSpaceId: number,
searchSpaceName: string,
roleName?: string | null
) {
posthog.capture("search_space_invite_accepted", {
search_space_id: searchSpaceId,
search_space_name: searchSpaceName,
role_name: roleName,
});
}
export function trackSearchSpaceInviteDeclined(searchSpaceName?: string) {
posthog.capture("search_space_invite_declined", {
search_space_name: searchSpaceName,
});
}
export function trackSearchSpaceUserAdded(
searchSpaceId: number,
searchSpaceName: string,
roleName?: string | null
) {
posthog.capture("search_space_user_added", {
search_space_id: searchSpaceId,
search_space_name: searchSpaceName,
role_name: roleName,
});
}
export function trackSearchSpaceUsersViewed(
searchSpaceId: number,
userCount: number,
ownerCount: number
) {
posthog.capture("search_space_users_viewed", {
search_space_id: searchSpaceId,
user_count: userCount,
owner_count: ownerCount,
});
}
// ============================================
// CONNECTOR CONNECTION EVENTS
// ============================================
export function trackConnectorConnected(
searchSpaceId: number,
connectorType: string,
connectorId?: number
) {
posthog.capture("connector_connected", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
}
// ============================================
// INDEXING EVENTS
// ============================================
export function trackIndexWithDateRangeOpened(
searchSpaceId: number,
connectorType: string,
connectorId: number
) {
posthog.capture("index_with_date_range_opened", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
}
export function trackIndexWithDateRangeStarted(
searchSpaceId: number,
connectorType: string,
connectorId: number,
options?: {
hasStartDate?: boolean;
hasEndDate?: boolean;
}
) {
posthog.capture("index_with_date_range_started", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
has_start_date: options?.hasStartDate ?? false,
has_end_date: options?.hasEndDate ?? false,
});
}
export function trackQuickIndexClicked(
searchSpaceId: number,
connectorType: string,
connectorId: number
) {
posthog.capture("quick_index_clicked", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
}
export function trackConfigurePeriodicIndexingOpened(
searchSpaceId: number,
connectorType: string,
connectorId: number
) {
posthog.capture("configure_periodic_indexing_opened", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
}
export function trackPeriodicIndexingStarted(
searchSpaceId: number,
connectorType: string,
connectorId: number,
frequencyMinutes: number
) {
posthog.capture("periodic_indexing_started", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
frequency_minutes: frequencyMinutes,
});
}
// ============================================
// USER IDENTIFICATION
// ============================================

View file

@ -1,4 +1,3 @@
import type { Message } from "@ai-sdk/react";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
@ -6,12 +5,6 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getChatTitleFromMessages(messages: Message[]) {
const userMessages = messages.filter((msg) => msg.role === "user");
if (userMessages.length === 0) return "Untitled Chat";
return userMessages[0].content;
}
export const formatDate = (date: Date): string => {
return date.toLocaleDateString("en-US", {
year: "numeric",

View file

@ -1,5 +1,6 @@
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
import Image, { type ImageProps } from "next/image";
import {
Accordion,
AccordionContent,
@ -7,16 +8,15 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import Image, { type ImageProps } from "next/image";
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
img: ({ className, alt, ...props }: React.ComponentProps<"img">) => (
<Image
{...(props as ImageProps)}
className={cn("rounded-md border", className)}
alt={alt ?? ""}
{...(props as ImageProps)}
/>
),
Video: ({ className, ...props }: React.ComponentProps<"video">) => (

View file

@ -265,7 +265,7 @@
"no_documents": "No documents found",
"type": "Type",
"content_summary": "Content Summary",
"view_full": "View Full Content",
"view_full": "View Summary",
"filter_placeholder": "Filter by title...",
"rows_per_page": "Rows per page",
"refresh": "Refresh",

View file

@ -1,6 +1,6 @@
{
"name": "surfsense_web",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"description": "SurfSense Frontend",
"scripts": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 123 KiB