diff --git a/apps/web/app/auth/agent-connect/page.tsx b/apps/web/app/auth/agent-connect/page.tsx new file mode 100644 index 00000000..86838d41 --- /dev/null +++ b/apps/web/app/auth/agent-connect/page.tsx @@ -0,0 +1 @@ +export { default } from "../connect/page" diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index eade1f09..0f96e52c 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -99,7 +99,7 @@ const PLUGIN_INFO: Record = { "Captures coding decisions and patterns automatically", "Builds persistent user profile across projects", ], - icon: "/images/plugins/codex.svg", + icon: "/images/plugins/codex.png", }, } @@ -199,6 +199,7 @@ function AuthConnectContent() { const redirectUrl = new URL(callback) redirectUrl.searchParams.set("apikey", data.key) + redirectUrl.searchParams.set("api_url", API_URL) window.location.href = redirectUrl.toString() } catch (err) { console.error("Failed to get API key:", err) diff --git a/apps/web/components/document-cards/plugin-preview.tsx b/apps/web/components/document-cards/plugin-preview.tsx index e4b90419..bd40e76c 100644 --- a/apps/web/components/document-cards/plugin-preview.tsx +++ b/apps/web/components/document-cards/plugin-preview.tsx @@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) { )} {parsed.pluginLabel} -

- {parsed.formatLabel} -

- {parsed.identifierValue && ( -

- {parsed.identifierValue} -

- )}

{parsed.pluginLabel} - - {parsed.formatLabel} - - {parsed.identifierLabel && parsed.identifierValue && ( - - {parsed.identifierLabel}: {parsed.identifierValue} - - )}

type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -270,13 +269,8 @@ export function DocumentModal({ ], ) - const hasPluginInsights = - pluginDocument && - pluginDocument.kind !== "claude-code-doc" && - pluginDocument.kind !== "openclaw-session" const hasDocumentInsights = Boolean( - hasPluginInsights || - _document?.summary || + _document?.summary || pluginDocument?.summary || (_document?.memoryEntries && _document.memoryEntries.length > 0), ) @@ -303,7 +297,6 @@ export function DocumentModal({ dmSansClassName(), )} > - {hasPluginInsights && } {_document && (_document.summary || pluginDocument?.summary) && ( -

- {label} -

-

{value}

- - ) -} - -export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) { - return ( -
-
-

- Details -

- - {parsed.pluginIconSrc && ( - - )} - {parsed.pluginLabel} - -
-
- - {parsed.identifierLabel && parsed.identifierValue && ( - - )} - {parsed.clientLabel && parsed.clientValue && ( - - )} -
- {parsed.artifacts.length > 0 && ( -
-

- Outputs -

-
- {parsed.artifacts.map((artifact, index) => ( - - ))} -
-
- )} -
- ) -} diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 5a5a7161..f24188bf 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -23,7 +23,6 @@ import { ArrowRight, BookOpen, Check, - ChevronDown, Loader, Search, X, @@ -46,11 +45,11 @@ import { import { AnimatePresence, motion } from "motion/react" import { toast } from "sonner" import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" -import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" import { PLUGIN_CATALOG, FREE_TIER_PLUGIN_IDS, isFreeTierPlugin, + normalizePluginClientId, type InstallStep, } from "@/lib/plugin-catalog" import { INSET, InstallSteps, PillButton } from "./integrations/install-steps" @@ -66,6 +65,16 @@ interface ConnectedKey { pluginId: string } +type ListedApiKey = { + id: string + name?: string | null + createdAt?: string + enabled?: boolean + lastRequest?: string | null + metadata: string | Record | null + start?: string | null +} + type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import" type MCPClientKey = @@ -487,56 +496,20 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) { ) } -function ConnectedPill({ - keys, - onRevoke, -}: { - keys: ConnectedKey[] - onRevoke: (keyId: string) => void -}) { +function ConnectedButton({ onClick }: { onClick: () => void }) { return ( - - - - - e.stopPropagation()} - className={cn( - dmSans125ClassName(), - "w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]", - )} - > -

- {keys.length > 1 ? `${keys.length} connections` : "Connection"} -

-
- {keys.map((k) => ( -
- - {k.keyStart ? `${k.keyStart}…` : "API key"} - - onRevoke(k.keyId)} /> -
- ))} -
-
-
+ ) } @@ -1002,6 +975,9 @@ export function IntegrationsView() { key: string pluginId: string | null }>({ open: false, key: "", pluginId: null }) + const [connectedPluginId, setConnectedPluginId] = useState( + null, + ) const { data: pluginsData } = useQuery({ queryFn: async () => { @@ -1030,27 +1006,34 @@ export function IntegrationsView() { enabled: hasProProduct, }) - type ApiKey = { - id: string - metadata: Record | null - start: string | null - } - const { data: apiKeys = [], refetch: refetchKeys } = useQuery({ - queryKey: ["api-keys", org?.id], - queryFn: async () => { - if (!org?.id) return [] - const data = (await authClient.apiKey.list({ - fetchOptions: { query: { metadata: { organizationId: org.id } } }, - })) as unknown as ApiKey[] - return data.filter((key) => key.metadata?.organizationId === org.id) + const { data: apiKeys = [], refetch: refetchKeys } = useQuery( + { + queryKey: ["api-keys", org?.id], + queryFn: async () => { + if (!org?.id) return [] + const API_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const res = await fetch(`${API_URL}/v3/auth/keys`, { + credentials: "include", + }) + if (!res.ok) return [] + const data = (await res.json()) as { keys?: ListedApiKey[] } + return data.keys ?? [] + }, + enabled: !!org?.id, + staleTime: 30 * 1000, }, - enabled: !!org?.id, - staleTime: 30 * 1000, - }) + ) + + const keyPrefix = useCallback((key: ListedApiKey): string | null => { + return key.start ?? (key.name?.startsWith("sm_") ? key.name : null) + }, []) const connectedPlugins = useMemo(() => { const out: ConnectedKey[] = [] for (const key of apiKeys) { + if (key.enabled === false) continue + if (!key.lastRequest) continue if (!key.metadata) continue try { const metadata = @@ -1063,14 +1046,14 @@ export function IntegrationsView() { if (metadata.sm_type === "plugin_auth" && metadata.sm_client) { out.push({ keyId: key.id, - keyStart: key.start ?? null, - pluginId: metadata.sm_client, + keyStart: keyPrefix(key), + pluginId: normalizePluginClientId(metadata.sm_client), }) } } catch {} } return out - }, [apiKeys]) + }, [apiKeys, keyPrefix]) const connectionsByProvider = useMemo(() => { const out: Record = { @@ -1368,7 +1351,14 @@ export function IntegrationsView() { const needsProUpgrade = !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId) if (keys.length > 0) { - return + return ( + { + trackCard(item) + setConnectedPluginId(item.pluginId) + }} + /> + ) } if (needsProUpgrade) { return ( @@ -1500,12 +1490,7 @@ export function IntegrationsView() { const renderLeftIndicator = (item: Item): ReactNode => { if (item.kind === "plugin") { - const isConnected = connectedPlugins.some( - (k) => k.pluginId === item.pluginId, - ) - return isConnected ? ( - - ) : null + return null } if (item.kind === "connector") { const count = connectionsByProvider[item.provider].length @@ -1519,6 +1504,12 @@ export function IntegrationsView() { const dialogPlugin = newKey.pluginId ? PLUGIN_CATALOG[newKey.pluginId] : undefined + const connectedDialogPlugin = connectedPluginId + ? PLUGIN_CATALOG[connectedPluginId] + : undefined + const connectedDialogKeys = connectedPluginId + ? connectedPlugins.filter((key) => key.pluginId === connectedPluginId) + : [] const pluginSteps = dialogPlugin?.installSteps ?? [] const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_...")) const setupSteps: InstallStep[] = stepsEmbedKey @@ -1699,6 +1690,123 @@ export function IntegrationsView() { + { + if (!open) setConnectedPluginId(null) + }} + > + + + {connectedDialogPlugin?.name ?? "Plugin"} connection + +
+ {connectedDialogPlugin && ( + + {connectedDialogPlugin.name} + + )} +
+

+ {connectedDialogPlugin?.name ?? "Plugin"} connected +

+

+ + Active +

+
+
+ {connectedDialogPlugin?.docsUrl && ( + + Docs + + )} + + + +
+
+
+

+ {connectedDialogKeys.length > 1 + ? `${connectedDialogKeys.length} connections` + : "Connection"} +

+ {connectedDialogKeys.length > 0 ? ( +
+ {connectedDialogKeys.map((key) => ( +
+ + {key.keyStart ? `${key.keyStart}...` : "API key"} + + void handleRevokePluginKey(key.keyId)} + /> +
+ ))} +
+ ) : ( +

+ No active connection was found. +

+ )} +
+
+ + + +
+
+
+ { diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index 053e7156..a77ec214 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -8,7 +8,15 @@ 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 { + BookOpen, + Check, + ChevronDown, + ExternalLink, + Loader, + X, + Zap, +} from "lucide-react" import Image from "next/image" import { type ReactNode, useEffect, useMemo, useState } from "react" import { toast } from "sonner" @@ -17,6 +25,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" import { PLUGIN_CATALOG, isFreeTierPlugin, + normalizePluginClientId, type InstallStep, type PluginInfo, } from "@/lib/plugin-catalog" @@ -27,9 +36,20 @@ interface ConnectedPlugin { keyId: string pluginId: string createdAt: string + lastRequest?: string | null keyStart?: string | null } +type ListedApiKey = { + id: string + name?: string | null + createdAt: string + enabled?: boolean + lastRequest: string | null + metadata: string | Record | null + start?: string | null +} + function SectionHeader({ children }: { children: ReactNode }) { return (

void }) { ) } -function ConnectedPill({ +function toDate(value: string | Date | null | undefined): Date | null { + if (!value) return null + const date = value instanceof Date ? value : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function formatRelativeTime(value: string | Date | null | undefined): string { + const date = toDate(value) + if (!date) return "Never" + const absMs = Math.abs(Date.now() - date.getTime()) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (absMs < minute) return "Just now" + if (absMs < hour) return `${Math.round(absMs / minute)}m ago` + if (absMs < day) return `${Math.round(absMs / hour)}h ago` + return `${Math.round(absMs / day)}d ago` +} + +function formatDate(value: string | Date | null | undefined): string { + const date = toDate(value) + if (!date) return "Unknown" + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function maskKey(start: string | null | undefined): string { + if (!start) return "sm_********" + return `${start}********` +} + +function keyPrefix(key: ListedApiKey): string | null { + return key.start ?? (key.name?.startsWith("sm_") ? key.name : null) +} + +function DetailStat({ label, value }: { label: string; value: string }) { + return ( +

+

+ {label} +

+

+ {value} +

+
+ ) +} + +function ActivePill({ + plugin, connectedKeys, onRevoke, }: { + plugin: PluginInfo connectedKeys: ConnectedPlugin[] onRevoke: (keyId: string) => void }) { + const primaryKey = connectedKeys[0] return ( @@ -140,7 +224,7 @@ function ConnectedPill({ )} > - Connected + ACTIVE @@ -148,13 +232,67 @@ function ConnectedPill({ align="end" className={cn( dmSans125ClassName(), - "w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]", + "w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/10 bg-[#1B1F24] p-4 text-[#FAFAFA]", )} > +
+ +
+

+ {plugin.name} +

+

Connected

+
+ +
+ +
+ + + +
+ +
+ {plugin.docsUrl && ( + + Docs + + )} + {plugin.githubUrl && ( + + GitHub + + )} + + Connected {formatDate(primaryKey?.createdAt)} + +
+

{connectedKeys.length > 1 @@ -240,7 +378,11 @@ function PluginRow({

{plugin.docsUrl && } {isConnected ? ( - + ) : needsProUpgrade ? ( Upgrade @@ -332,21 +474,29 @@ export function PluginsDetail() { 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) + const { data: apiKeys = [], refetch: refetchKeys } = useQuery( + { + enabled: !!org?.id, + queryFn: async () => { + if (!org?.id) return [] + const API_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const res = await fetch(`${API_URL}/v3/auth/keys`, { + credentials: "include", + }) + if (!res.ok) return [] + const data = (await res.json()) as { keys?: ListedApiKey[] } + return data.keys ?? [] + }, + queryKey: ["api-keys", org?.id], }, - queryKey: ["api-keys", org?.id], - }) + ) const connectedPlugins = useMemo(() => { const plugins: ConnectedPlugin[] = [] for (const key of apiKeys) { + if (key.enabled === false) continue + if (!key.lastRequest) continue if (!key.metadata) continue try { const metadata = @@ -358,12 +508,15 @@ export function PluginsDetail() { : (key.metadata as { sm_type?: string; sm_client?: string }) if (metadata.sm_type === "plugin_auth" && metadata.sm_client) { + const createdAt = toDate(key.createdAt)?.toISOString() + const lastRequest = toDate(key.lastRequest)?.toISOString() plugins.push({ id: key.id, keyId: key.id, - pluginId: metadata.sm_client, - createdAt: key.createdAt.toISOString(), - keyStart: key.start ?? null, + pluginId: normalizePluginClientId(metadata.sm_client), + createdAt: createdAt ?? new Date().toISOString(), + lastRequest: lastRequest ?? null, + keyStart: keyPrefix(key), }) } } catch {} diff --git a/apps/web/lib/plugin-catalog.ts b/apps/web/lib/plugin-catalog.ts index f94a4e92..f16d087e 100644 --- a/apps/web/lib/plugin-catalog.ts +++ b/apps/web/lib/plugin-catalog.ts @@ -14,6 +14,7 @@ export interface PluginInfo { tagline: string icon: string docsUrl?: string + githubUrl?: string /** Steps shown after a key is minted. The literal `sm_...` is replaced * with the freshly generated key when rendered. */ installSteps?: InstallStep[] @@ -55,6 +56,7 @@ export const PLUGIN_CATALOG: Record = { tagline: "Persistent memory for the Codex CLI — free on every plan", icon: "/images/plugins/codex.png", docsUrl: "https://docs.supermemory.ai/integrations/codex", + githubUrl: "https://github.com/supermemoryai/codex-supermemory", installSteps: [ { title: "Save your API key",