-
Notifications
You must be signed in to change notification settings - Fork 10
feat: add Connectors page UI with disconnect functionality #1455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
565e6c8
286f50f
4123cbe
df9eb8c
f6cd062
8517377
dcc44dd
27b284c
11e80c0
597fda9
8ddeac0
4ea3299
9fb833b
32ad2a8
bd1a00a
55208e5
0865be2
3a13ad6
c07f9c5
3cac7fd
49040c8
ff929b6
7b9e93e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| "use client"; | ||
|
|
||
| import { Suspense } from "react"; | ||
| import { ConnectorsPage } from "@/components/ConnectorsPage"; | ||
|
|
||
| export default function SettingsConnectorsPage() { | ||
| return ( | ||
| <Suspense | ||
| fallback={ | ||
| <div className="flex h-full items-center justify-center"> | ||
| <p className="text-muted-foreground">Loading...</p> | ||
| </div> | ||
| } | ||
| > | ||
| <ConnectorsPage /> | ||
| </Suspense> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| "use client"; | ||
|
|
||
| import { ConnectorInfo } from "@/hooks/useConnectors"; | ||
| import { useConnectorHandlers } from "@/hooks/useConnectorHandlers"; | ||
| import { getConnectorMeta } from "@/lib/composio/connectorMetadata"; | ||
| import { formatConnectorName } from "@/lib/composio/formatConnectorName"; | ||
| import { getConnectorIcon } from "@/lib/composio/getConnectorIcon"; | ||
| import { ConnectorConnectedMenu } from "./ConnectorConnectedMenu"; | ||
| import { ConnectorEnableButton } from "./ConnectorEnableButton"; | ||
|
|
||
| interface ConnectorCardProps { | ||
| connector: ConnectorInfo; | ||
| onConnect: (slug: string) => Promise<string | null>; | ||
| onDisconnect: (connectedAccountId: string) => Promise<boolean>; | ||
| } | ||
|
|
||
| /** | ||
| * Card component for a single connector. | ||
| */ | ||
| export function ConnectorCard({ | ||
| connector, | ||
| onConnect, | ||
| onDisconnect, | ||
| }: ConnectorCardProps) { | ||
| const { isConnecting, isDisconnecting, handleConnect, handleDisconnect } = | ||
| useConnectorHandlers({ | ||
| slug: connector.slug, | ||
| connectedAccountId: connector.connectedAccountId, | ||
| onConnect, | ||
| onDisconnect, | ||
| }); | ||
| const meta = getConnectorMeta(connector.slug); | ||
|
|
||
| return ( | ||
| <div className="group flex items-center gap-4 p-4 rounded-xl border border-border bg-card transition-all duration-200 hover:border-muted-foreground/30 hover:shadow-sm"> | ||
| <div className="shrink-0 p-2.5 rounded-xl transition-colors bg-muted/50 group-hover:bg-muted"> | ||
| {getConnectorIcon(connector.slug, 22)} | ||
| </div> | ||
|
|
||
| <div className="flex-1 min-w-0"> | ||
| <h3 className="font-medium text-foreground truncate"> | ||
| {formatConnectorName(connector.name, connector.slug)} | ||
| </h3> | ||
| <p className="text-sm text-muted-foreground truncate"> | ||
| {meta.description} | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="shrink-0"> | ||
| {connector.isConnected ? ( | ||
| <ConnectorConnectedMenu | ||
| isDisconnecting={isDisconnecting} | ||
| onReconnect={handleConnect} | ||
| onDisconnect={handleDisconnect} | ||
| /> | ||
| ) : ( | ||
| <ConnectorEnableButton | ||
| isConnecting={isConnecting} | ||
| onClick={handleConnect} | ||
| /> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { Loader2, MoreVertical, RefreshCw, Unlink, CheckCircle } from "lucide-react"; | ||
| import { | ||
| DropdownMenu, | ||
| DropdownMenuContent, | ||
| DropdownMenuItem, | ||
| DropdownMenuTrigger, | ||
| } from "@/components/ui/dropdown-menu"; | ||
|
|
||
| interface ConnectorConnectedMenuProps { | ||
| isDisconnecting: boolean; | ||
| onReconnect: () => void; | ||
| onDisconnect: () => void; | ||
| } | ||
|
|
||
| export function ConnectorConnectedMenu({ | ||
| isDisconnecting, | ||
| onReconnect, | ||
| onDisconnect, | ||
| }: ConnectorConnectedMenuProps) { | ||
| return ( | ||
| <div className="flex items-center gap-2"> | ||
| <span className="text-xs font-medium text-muted-foreground flex items-center gap-1"> | ||
| Connected | ||
| <CheckCircle className="h-3.5 w-3.5 text-green-600 dark:text-green-500" /> | ||
| </span> | ||
| <DropdownMenu> | ||
| <DropdownMenuTrigger asChild> | ||
| <button | ||
| className="p-1.5 rounded-md hover:bg-muted transition-colors" | ||
| title="Connector options" | ||
| > | ||
| <MoreVertical className="h-4 w-4 text-muted-foreground" /> | ||
| </button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end"> | ||
| <DropdownMenuItem onClick={onReconnect} className="cursor-pointer"> | ||
| <RefreshCw className="h-4 w-4 mr-2" /> | ||
| Reconnect | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem | ||
| onClick={onDisconnect} | ||
| disabled={isDisconnecting} | ||
| className="cursor-pointer text-red-600 dark:text-red-400" | ||
| > | ||
| {isDisconnecting ? ( | ||
| <Loader2 className="h-4 w-4 mr-2 animate-spin" /> | ||
| ) : ( | ||
| <Unlink className="h-4 w-4 mr-2" /> | ||
| )} | ||
| {isDisconnecting ? "Disconnecting..." : "Disconnect"} | ||
| </DropdownMenuItem> | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { Loader2 } from "lucide-react"; | ||
|
|
||
| interface ConnectorEnableButtonProps { | ||
| isConnecting: boolean; | ||
| onClick: () => void; | ||
| } | ||
|
|
||
| export function ConnectorEnableButton({ | ||
| isConnecting, | ||
| onClick, | ||
| }: ConnectorEnableButtonProps) { | ||
| return ( | ||
| <button | ||
| onClick={onClick} | ||
| disabled={isConnecting} | ||
| className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-foreground bg-transparent border border-border hover:bg-muted rounded-lg transition-colors disabled:opacity-50" | ||
| > | ||
| {isConnecting && <Loader2 className="h-4 w-4 animate-spin" />} | ||
| Enable | ||
| </button> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export function ConnectorsEmptyState() { | ||
| return ( | ||
| <p className="text-center text-muted-foreground py-12"> | ||
| No connectors available. | ||
| </p> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| interface ConnectorsErrorBannerProps { | ||
| error: string | null; | ||
| } | ||
|
|
||
| export function ConnectorsErrorBanner({ error }: ConnectorsErrorBannerProps) { | ||
| if (!error) return null; | ||
|
|
||
| return ( | ||
| <div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm"> | ||
| {error} | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { Plug, RefreshCw } from "lucide-react"; | ||
|
|
||
| interface ConnectorsHeaderProps { | ||
| onRefresh: () => void; | ||
| isLoading: boolean; | ||
| } | ||
|
|
||
| export function ConnectorsHeader({ onRefresh, isLoading }: ConnectorsHeaderProps) { | ||
| return ( | ||
| <div className="flex items-center justify-between mb-8"> | ||
| <div> | ||
| <h1 className="text-2xl font-semibold flex items-center gap-2"> | ||
| <Plug className="h-6 w-6" /> | ||
| Connectors | ||
| </h1> | ||
| <p className="text-muted-foreground mt-1"> | ||
| Connect your tools to enable AI-powered automation | ||
| </p> | ||
| </div> | ||
| <button | ||
| onClick={onRefresh} | ||
| disabled={isLoading} | ||
| className="p-2 rounded-lg hover:bg-muted transition-colors disabled:opacity-50" | ||
| title="Refresh" | ||
| > | ||
| <RefreshCw | ||
| className={`h-5 w-5 text-muted-foreground ${isLoading ? "animate-spin" : ""}`} | ||
| /> | ||
| </button> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { Loader2 } from "lucide-react"; | ||
|
|
||
| export function ConnectorsLoadingState() { | ||
| return ( | ||
| <div className="flex items-center justify-center py-20"> | ||
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,81 @@ | ||||||
| "use client"; | ||||||
|
|
||||||
| import { Fragment, useEffect, useState } from "react"; | ||||||
| import { usePrivy } from "@privy-io/react-auth"; | ||||||
| import { useSearchParams } from "next/navigation"; | ||||||
| import { useUserProvider } from "@/providers/UserProvder"; | ||||||
| import { useConnectors } from "@/hooks/useConnectors"; | ||||||
| import { ConnectorsSuccessBanner } from "./ConnectorsSuccessBanner"; | ||||||
| import { ConnectorsErrorBanner } from "./ConnectorsErrorBanner"; | ||||||
| import { ConnectorsLoadingState } from "./ConnectorsLoadingState"; | ||||||
| import { ConnectorsEmptyState } from "./ConnectorsEmptyState"; | ||||||
| import { ConnectorsSection } from "./ConnectorsSection"; | ||||||
| import { ConnectorsHeader } from "./ConnectorsHeader"; | ||||||
|
|
||||||
| /** | ||||||
| * Main connectors page component. | ||||||
| * Redesigned to match Perplexity's Connectors style. | ||||||
| */ | ||||||
| export function ConnectorsPage() { | ||||||
| const { userData } = useUserProvider(); | ||||||
| const { ready } = usePrivy(); | ||||||
| const { connectors, isLoading, error, refetch, authorize, disconnect } = | ||||||
| useConnectors(); | ||||||
| const searchParams = useSearchParams(); | ||||||
| const [showSuccess, setShowSuccess] = useState(false); | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| if (searchParams.get("connected") === "true") { | ||||||
| setShowSuccess(true); | ||||||
| refetch(); | ||||||
| window.history.replaceState({}, "", "/settings/connectors"); | ||||||
| const timer = setTimeout(() => setShowSuccess(false), 5000); | ||||||
| return () => clearTimeout(timer); | ||||||
| } | ||||||
| }, [searchParams, refetch]); | ||||||
|
|
||||||
| if (!ready) return <Fragment />; | ||||||
|
|
||||||
| if (!userData?.account_id) { | ||||||
| return ( | ||||||
| <div className="flex h-full items-center justify-center p-4"> | ||||||
| <p className="text-muted-foreground"> | ||||||
| Please sign in to manage connectors. | ||||||
| </p> | ||||||
| </div> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| const connected = connectors.filter((c) => c.isConnected); | ||||||
| const available = connectors.filter((c) => !c.isConnected); | ||||||
|
|
||||||
| return ( | ||||||
| <main className="min-h-screen p-4 md:p-8 max-w-5xl mx-auto"> | ||||||
| <ConnectorsSuccessBanner show={showSuccess} /> | ||||||
| <ConnectorsHeader onRefresh={refetch} isLoading={isLoading} /> | ||||||
| <ConnectorsErrorBanner error={error} /> | ||||||
|
|
||||||
| {isLoading ? ( | ||||||
| <ConnectorsLoadingState /> | ||||||
| ) : ( | ||||||
| <div className="space-y-10"> | ||||||
| <ConnectorsSection | ||||||
| title="Installed Connectors" | ||||||
| description="Connected tools provide richer and more accurate answers, gated by permissions you have granted." | ||||||
| connectors={connected} | ||||||
| onConnect={authorize} | ||||||
| onDisconnect={disconnect} | ||||||
| /> | ||||||
| <ConnectorsSection | ||||||
| title="Available Connectors" | ||||||
| description="Connect your tools to search across them and take action. Your permissions are always respected." | ||||||
| connectors={available} | ||||||
| onConnect={authorize} | ||||||
| onDisconnect={disconnect} | ||||||
| /> | ||||||
| {connectors.length === 0 && <ConnectorsEmptyState />} | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The empty state is displayed alongside the error banner when a fetch error occurs, which misleads users into thinking there are legitimately no connectors rather than showing that an error occurred. View DetailsAnalysisEmpty state displayed alongside error banner when connector fetch failsWhat fails: ConnectorsPage.tsx displays both the error banner and "No connectors available" empty state message simultaneously when the connector API fetch fails, creating a confusing mixed message to users. How to reproduce:
Result: Both error banner ("Failed to fetch connectors") and empty state message ("No connectors available") render together, creating conflicting messages - the empty state suggests zero results (legitimate scenario) while error banner indicates failure (error scenario). Expected: When a fetch error occurs, only the error banner should display. The empty state should only display when the fetch succeeds with legitimately zero connectors. Root cause: Line 74 of ConnectorsPage.tsx conditionally renders the empty state with only Fix: Updated ConnectorsPage.tsx line 74 to check for absence of error: {connectors.length === 0 && !error && <ConnectorsEmptyState />}This ensures the empty state only displays when there is genuinely no data (successful fetch with zero results), not when an error prevented data retrieval. |
||||||
| </div> | ||||||
| )} | ||||||
| </main> | ||||||
| ); | ||||||
| } | ||||||
sweetmantech marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { ConnectorInfo } from "@/hooks/useConnectors"; | ||
| import { ConnectorCard } from "./ConnectorCard"; | ||
|
|
||
| interface ConnectorsSectionProps { | ||
| title: string; | ||
| description: string; | ||
| connectors: ConnectorInfo[]; | ||
| onConnect: (slug: string) => Promise<string | null>; | ||
| onDisconnect: (connectedAccountId: string) => Promise<boolean>; | ||
| } | ||
|
|
||
| export function ConnectorsSection({ | ||
| title, | ||
| description, | ||
| connectors, | ||
| onConnect, | ||
| onDisconnect, | ||
| }: ConnectorsSectionProps) { | ||
| if (connectors.length === 0) return null; | ||
|
|
||
| return ( | ||
| <section> | ||
| <div className="mb-4"> | ||
| <h2 className="text-lg font-semibold">{title}</h2> | ||
| <p className="text-sm text-muted-foreground">{description}</p> | ||
| </div> | ||
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> | ||
| {connectors.map((connector) => ( | ||
| <ConnectorCard | ||
| key={connector.slug} | ||
| connector={connector} | ||
| onConnect={onConnect} | ||
| onDisconnect={onDisconnect} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </section> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { CheckCircle } from "lucide-react"; | ||
|
|
||
| interface ConnectorsSuccessBannerProps { | ||
| show: boolean; | ||
| } | ||
|
|
||
| export function ConnectorsSuccessBanner({ show }: ConnectorsSuccessBannerProps) { | ||
| if (!show) return null; | ||
|
|
||
| return ( | ||
| <div className="mb-6 flex items-center gap-2 px-4 py-3 rounded-lg bg-green-50 dark:bg-green-950/80 border border-green-200 dark:border-green-800 animate-in slide-in-from-top-2"> | ||
| <CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" /> | ||
| <span className="text-sm font-medium text-green-800 dark:text-green-200"> | ||
| Connector enabled successfully! | ||
| </span> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { ConnectorsPage } from "./ConnectorsPage"; | ||
| export { ConnectorCard } from "./ConnectorCard"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import Link from "next/link"; | ||
| import { Plug } from "lucide-react"; | ||
| import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; | ||
|
|
||
| const ConnectorsMenuItem = () => { | ||
| return ( | ||
| <DropdownMenuItem asChild className="cursor-pointer"> | ||
| <Link href="/settings/connectors"> | ||
| <Plug className="h-4 w-4" /> | ||
| Connectors | ||
| </Link> | ||
| </DropdownMenuItem> | ||
| ); | ||
| }; | ||
|
|
||
| export default ConnectorsMenuItem; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reconnect button missing disabled state during operation
Medium Severity
The
ConnectorConnectedMenucomponent receivesisDisconnectingto disable the Disconnect button during operation, butisConnectingis not passed to disable the Reconnect button. TheuseConnectorHandlershook tracks both states, andConnectorEnableButtoncorrectly usesisConnectingto disable itself, butConnectorConnectedMenulacks this prop entirely. Users can click "Reconnect" multiple times while an authorization request is in progress, potentially triggering multiple OAuth flows.Additional Locations (1)
components/ConnectorsPage/ConnectorCard.tsx#L50-L55