Merge remote-tracking branch 'upstream/dev' into sur-70-feature-streamline-onboarding-auto-create-default-workspace
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
48
surfsense_web/changelog/content/2026-01-08.mdx
Normal 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!
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ const defaultData = {
|
|||
user: {
|
||||
name: "Surf",
|
||||
email: "m@example.com",
|
||||
avatar: "/icon-128.png",
|
||||
avatar: "/icon-128.svg",
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./action-buttons";
|
||||
export * from "./schema";
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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"**
|
||||
|
||||

|
||||
|
||||
## 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"**
|
||||
|
||||

|
||||
|
||||
## 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 |
|
||||
|
||||

|
||||
|
||||
## 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"**
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"discord",
|
||||
"jira",
|
||||
"linear",
|
||||
"microsoft-teams",
|
||||
"confluence",
|
||||
"airtable",
|
||||
"clickup",
|
||||
|
|
@ -20,4 +21,3 @@
|
|||
],
|
||||
"defaultOpen": true
|
||||
}
|
||||
|
||||
|
|
|
|||
101
surfsense_web/content/docs/connectors/microsoft-teams.mdx
Normal 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
|
||||
|
||||

|
||||
|
||||
## Step 2: Create New Registration
|
||||
|
||||
1. On the **App registrations** page, click **"+ New registration"**
|
||||
|
||||

|
||||
|
||||
## 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"**
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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"**
|
||||
|
||||

|
||||
|
||||
6. **Important**: Copy the secret **Value** immediately - it won't be shown again!
|
||||
|
||||

|
||||
|
||||
> ⚠️ 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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
|
|||
"LINKUP_API",
|
||||
"BAIDU_SEARCH_API",
|
||||
"SLACK_CONNECTOR",
|
||||
"TEAMS_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"GITHUB_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">) => (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "surfsense_web",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
|
|
|
|||
BIN
surfsense_web/public/changelog/0.0.11/header.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
9
surfsense_web/public/icon-128.svg
Normal file
|
After Width: | Height: | Size: 123 KiB |