"use client"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { hasActivePlan } from "@lib/queries"
import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
import Image from "next/image"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import {
PLUGIN_CATALOG,
isFreeTierPlugin,
type InstallStep,
type PluginInfo,
} from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./install-steps"
interface ConnectedPlugin {
id: string
keyId: string
pluginId: string
createdAt: string
keyStart?: string | null
}
function SectionHeader({ children }: { children: ReactNode }) {
return (
{children}
)
}
function PluginIconBox({
src,
alt,
dimmed,
}: {
src: string
alt: string
dimmed?: boolean
}) {
return (
)
}
function ProChip() {
return (
Pro
)
}
function DocsLink({ href }: { href: string }) {
return (
{" "}
Docs
)
}
function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
const [confirming, setConfirming] = useState(false)
useEffect(() => {
if (!confirming) return
const t = setTimeout(() => setConfirming(false), 3000)
return () => clearTimeout(t)
}, [confirming])
return (
)
}
function ConnectedPill({
connectedKeys,
onRevoke,
}: {
connectedKeys: ConnectedPlugin[]
onRevoke: (keyId: string) => void
}) {
return (
{connectedKeys.length > 1
? `${connectedKeys.length} connections`
: "Connection"}
{connectedKeys.map((k) => (
{k.keyStart ? `${k.keyStart}…` : "API key"}
onRevoke(k.keyId)} />
))}
)
}
function PluginRow({
plugin,
pluginId,
connectedKeys,
needsProUpgrade,
isConnecting,
actionsDisabled,
onConnect,
onUpgrade,
onRevoke,
}: {
plugin: PluginInfo
pluginId: string
connectedKeys: ConnectedPlugin[]
needsProUpgrade: boolean
isConnecting: boolean
actionsDisabled: boolean
onConnect: (id: string) => void
onUpgrade: () => void
onRevoke: (keyId: string) => void
}) {
const isConnected = connectedKeys.length > 0
return (
{isConnected && (
)}
{plugin.name}
{!isConnected && needsProUpgrade &&
}
{plugin.tagline}
{plugin.docsUrl &&
}
{isConnected ? (
) : needsProUpgrade ? (
Upgrade
) : (
onConnect(pluginId)}
disabled={actionsDisabled}
>
{isConnecting ? (
<>
Connecting…
>
) : (
"Connect"
)}
)}
)
}
type TierFilter = "all" | "pro" | "free"
const TIER_FILTERS: { value: TierFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "pro", label: "Pro" },
{ value: "free", label: "Free" },
]
function TierFilterToggle({
value,
onChange,
}: {
value: TierFilter
onChange: (value: TierFilter) => void
}) {
return (
{TIER_FILTERS.map((filter) => (
))}
)
}
export function PluginsDetail() {
const { org } = useAuth()
const autumn = useCustomer()
const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState("all")
const [connectingPlugin, setConnectingPlugin] = useState(null)
const [newKey, setNewKey] = useState<{
open: boolean
key: string
pluginId: string | null
}>({
open: false,
key: "",
pluginId: null,
})
const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const { data: pluginsData } = useQuery({
queryFn: async () => {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const res = await fetch(`${API_URL}/v3/auth/plugins`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to fetch plugins")
return (await res.json()) as { plugins: string[] }
},
queryKey: ["plugins"],
})
const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
enabled: !!org?.id,
queryFn: async () => {
if (!org?.id) return []
const data = await authClient.apiKey.list({
fetchOptions: { query: { metadata: { organizationId: org.id } } },
})
return data.filter((key) => key.metadata?.organizationId === org.id)
},
queryKey: ["api-keys", org?.id],
})
const connectedPlugins = useMemo(() => {
const plugins: ConnectedPlugin[] = []
for (const key of apiKeys) {
if (!key.metadata) continue
try {
const metadata =
typeof key.metadata === "string"
? (JSON.parse(key.metadata) as {
sm_type?: string
sm_client?: string
})
: (key.metadata as { sm_type?: string; sm_client?: string })
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
plugins.push({
id: key.id,
keyId: key.id,
pluginId: metadata.sm_client,
createdAt: key.createdAt.toISOString(),
keyStart: key.start ?? null,
})
}
} catch {}
}
return plugins
}, [apiKeys])
const connectedPluginIds = useMemo(
() => new Set(connectedPlugins.map((p) => p.pluginId)),
[connectedPlugins],
)
const createPluginKeyMutation = useMutation({
mutationFn: async (pluginId: string) => {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const params = new URLSearchParams({ client: pluginId })
const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"This plugin requires a Pro plan. Hermes is available on the Free plan.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
throw new Error(errorData.message || "Failed to create plugin key")
}
return (await res.json()) as { key: string }
},
onMutate: (pluginId) => setConnectingPlugin(pluginId),
onError: (err) => {
toast.error("Failed to connect plugin", {
description: err instanceof Error ? err.message : "Unknown error",
})
},
onSettled: () => {
setConnectingPlugin(null)
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
},
onSuccess: (data, pluginId) => {
setNewKey({ open: true, key: data.key, pluginId })
},
})
const handleRevoke = async (keyId: string) => {
try {
await authClient.apiKey.delete({ keyId })
toast.success("Plugin disconnected")
refetchKeys()
} catch {
toast.error("Failed to disconnect plugin")
}
}
const handleUpgrade = async () => {
try {
const result = await autumn.attach({
planId: "api_pro",
successUrl: `${window.location.origin}/?view=integrations`,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
}
autumn.refetch?.()
} catch (error) {
console.error(error)
toast.error("Failed to start checkout. Please try again.")
}
}
const isLoading = autumn.isLoading
const availablePlugins = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
const catalogRows = useMemo(
() => availablePlugins.filter((id) => PLUGIN_CATALOG[id]),
[availablePlugins],
)
const visibleRows = useMemo(() => {
const filtered = catalogRows.filter((id) => {
if (tierFilter === "free") return isFreeTierPlugin(id)
if (tierFilter === "pro") return !isFreeTierPlugin(id)
return true
})
// Connected plugins float to the top (stable within each group).
return [...filtered].sort(
(a, b) =>
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
)
}, [catalogRows, tierFilter, connectedPluginIds])
const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId]
: undefined
const pluginSteps = dialogPlugin?.installSteps ?? []
// If a step already embeds the key (an `export …="sm_…"` line), don't also
// show the bare key in its own step — that's the repetition to avoid.
// Otherwise (wizard-style installs) lead with a copy-the-key step.
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
const setupSteps: InstallStep[] = stepsEmbedKey
? pluginSteps
: [
{
title: "Copy your API key",
description:
"You won't be able to see it again — store it somewhere safe.",
code: newKey.key,
copyLabel: "API key",
secret: true,
},
...pluginSteps,
]
return (
<>
Plugins
{catalogRows.length > 0 && (
)}
{visibleRows.map((pluginId) => {
const plugin = PLUGIN_CATALOG[pluginId]
if (!plugin) return null
const needsProUpgrade =
!isLoading && !hasProProduct && !isFreeTierPlugin(pluginId)
return (
p.pluginId === pluginId,
)}
needsProUpgrade={needsProUpgrade}
isConnecting={connectingPlugin === pluginId}
actionsDisabled={!!connectingPlugin}
onConnect={(id) => createPluginKeyMutation.mutate(id)}
onUpgrade={handleUpgrade}
onRevoke={handleRevoke}
/>
)
})}
{visibleRows.length === 0 && (
No plugins in this category.
)}
>
)
}