diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 0f96e52c0..febd27600 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -4,11 +4,10 @@ import { useAuth } from "@lib/auth-context" import { useSession } from "@lib/auth" import { cn } from "@lib/utils" import { dmSans125ClassName } from "@/lib/fonts" -import { useCustomer } from "autumn-js/react" -import { ArrowRight, Loader, XCircle } from "lucide-react" +import { ArrowRight, XCircle } from "lucide-react" import Image from "next/image" import { useRouter, useSearchParams } from "next/navigation" -import { Suspense, useEffect, useState } from "react" +import { Suspense, useEffect, useMemo, useState } from "react" import { PENDING_CONNECT_URL_KEY } from "@/lib/constants" @@ -88,7 +87,7 @@ const PLUGIN_INFO: Record = { "Auto-capture of project decisions", "Context-aware suggestions", ], - icon: "/images/plugins/cursor.svg", + icon: "/images/plugins/cursor.png", }, codex: { name: "OpenAI Codex", @@ -103,11 +102,77 @@ const PLUGIN_INFO: Record = { }, } +const MULTI_PLUGIN_FEATURES = [ + "Share one persistent memory layer across selected coding agents.", + "Recall project context, coding decisions, and prior sessions.", + "Connect every selected plugin with one approval.", +] + +function isKnownPlugin(value: string): boolean { + return Object.hasOwn(PLUGIN_INFO, value) +} + function getPluginName(client: string): string { return PLUGIN_INFO[client]?.name ?? "External Tool" } -type Status = "loading" | "creating" | "success" | "error" | "upgrade" +function formatPluginNames(clients: string[]): string { + const names = clients.map((id) => getPluginName(id)) + if (names.length === 0) return "External Tool" + if (names.length === 1) return names[0] ?? "External Tool" + if (names.length === 2) { + return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}` + } + + return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}` +} + +function encodeBase64UrlJson(value: Record): string { + return btoa(JSON.stringify(value)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +function PluginLogoStack({ clients }: { clients: string[] }) { + if (clients.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+ {clients.map((id, index) => { + const plugin = PLUGIN_INFO[id] + return ( +
+ {plugin ? ( + {plugin.name} + ) : ( + + )} +
+ ) + })} +
+ ) +} + +type Status = "loading" | "creating" | "success" | "error" const pageWrapperClass = "flex items-center justify-center min-h-screen bg-background p-4" @@ -121,16 +186,34 @@ function AuthConnectContent() { const router = useRouter() const { data: session, isPending } = useSession() const { org, organizations, isRestoring } = useAuth() - const autumn = useCustomer() const [status, setStatus] = useState("loading") const [error, setError] = useState(null) - const [isUpgrading, setIsUpgrading] = useState(false) const callback = params.get("callback") const client = params.get("client") - const validClient = client && client in PLUGIN_INFO ? client : null - const displayName = validClient ? getPluginName(validClient) : "External Tool" - const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null + const clientsParam = params.get("clients") + const hasClientList = params.has("clients") + const rawRequestedClients = useMemo( + () => + (clientsParam !== null ? clientsParam.split(",") : client ? [client] : []) + .map((value) => value.trim()) + .filter(Boolean), + [client, clientsParam], + ) + const requestedClients = useMemo( + () => Array.from(new Set(rawRequestedClients.filter(isKnownPlugin))), + [rawRequestedClients], + ) + const invalidClients = useMemo( + () => rawRequestedClients.filter((value) => !isKnownPlugin(value)), + [rawRequestedClients], + ) + const validClient = requestedClients[0] ?? null + const displayName = formatPluginNames(requestedClients) + const pluginInfo = + requestedClients.length === 1 && validClient + ? PLUGIN_INFO[validClient] + : null // Redirect new users (logged in but no organization) to onboarding. // Store the current connect URL so onboarding can redirect back here. @@ -166,6 +249,16 @@ function AuthConnectContent() { setError("Invalid callback URL.") return } + if (invalidClients.length > 0) { + setStatus("error") + setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) + return + } + if (requestedClients.length === 0) { + setStatus("error") + setError("Invalid or missing client.") + return + } if (!session || !org) { setStatus("error") setError( @@ -177,17 +270,13 @@ function AuthConnectContent() { try { setStatus("creating") const fetchParams = new URLSearchParams({ callback }) - if (validClient) fetchParams.set("client", validClient) + fetchParams.set("client", requestedClients[0] ?? "") const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, { credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - setStatus("upgrade") - return - } const errorData = (await res.json().catch(() => ({}))) as { message?: string } @@ -198,7 +287,21 @@ function AuthConnectContent() { setStatus("success") const redirectUrl = new URL(callback) - redirectUrl.searchParams.set("apikey", data.key) + if (hasClientList) { + redirectUrl.searchParams.set( + "keys", + encodeBase64UrlJson( + Object.fromEntries( + requestedClients.map((requestedClient) => [ + requestedClient, + data.key, + ]), + ), + ), + ) + } else { + redirectUrl.searchParams.set("apikey", data.key) + } redirectUrl.searchParams.set("api_url", API_URL) window.location.href = redirectUrl.toString() } catch (err) { @@ -208,23 +311,23 @@ function AuthConnectContent() { } } - async function handleUpgrade() { - try { - setIsUpgrading(true) - const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}` - await autumn.attach({ - planId: "api_pro", - successUrl: safeSuccessUrl, - }) - } catch (err) { - console.error("Upgrade failed:", err) - setIsUpgrading(false) - } - } - // Show a spinner while session/org data is loading or while we're about // to redirect to onboarding (prevents a brief flash of the connect card). const isAuthLoading = isPending || isRestoring || organizations === null + + useEffect(() => { + if (status !== "loading") return + if (rawRequestedClients.length === 0) { + setStatus("error") + setError("Invalid or missing client.") + return + } + if (invalidClients.length > 0) { + setStatus("error") + setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`) + } + }, [invalidClients, rawRequestedClients.length, status]) + if (isAuthLoading || shouldRedirectToOnboarding) { return (
@@ -238,19 +341,7 @@ function AuthConnectContent() {
-
- {pluginInfo ? ( - {pluginInfo.name} - ) : ( - - )} -
+

{pluginInfo?.description ?? - `Allow ${displayName} to access your Supermemory account.`} + (requestedClients.length > 1 + ? "Use one Supermemory account across these plugins." + : `Use your Supermemory account with ${displayName}.`)}

- {pluginInfo && ( -
    - {pluginInfo.features.map((feature) => ( +
      + {(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map( + (feature) => (
    • - ))} -
    - )} + ), + )} +
- - - View all plans - -
-
-
- ) - } - if (status === "error") { return (
@@ -435,7 +430,7 @@ function AuthConnectContent() {
- ))} -
- ) -} - export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() const queryClient = useQueryClient() - const [tierFilter, setTierFilter] = useState("all") const [connectingPlugin, setConnectingPlugin] = useState(null) const [finishSetupPluginId, setFinishSetupPluginId] = useState( null, @@ -572,11 +534,6 @@ export function PluginsDetail() { credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - throw new Error( - "Plugin access was denied. Check your plan or try again.", - ) - } const errorData = (await res.json().catch(() => ({}))) as { message?: string } @@ -635,17 +592,12 @@ export function PluginsDetail() { ) 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( + return [...catalogRows].sort( (a, b) => Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)), ) - }, [catalogRows, tierFilter, connectedPluginIds]) + }, [catalogRows, connectedPluginIds]) const dialogPlugin = newKey.pluginId ? PLUGIN_CATALOG[newKey.pluginId] @@ -684,12 +636,7 @@ export function PluginsDetail() { )} >
-
- Plugins - {catalogRows.length > 0 && ( - - )} -
+ Plugins
{visibleRows.map((pluginId) => { const plugin = PLUGIN_CATALOG[pluginId] diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx index 5535392de..d37339919 100644 --- a/apps/web/components/onboarding-brain/step-sources.tsx +++ b/apps/web/components/onboarding-brain/step-sources.tsx @@ -149,11 +149,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [ credits: "$20", productId: "api_pro", description: "For people building with AI memory", - features: [ - "Auto top-up when balance runs low", - "All plugins (Claude Code, Cursor, Hermes...)", - "Priority support", - ], + features: ["Auto top-up when balance runs low", "Priority support"], }, { id: "max", diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index c9a06dd05..7b7398c6d 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -394,11 +394,6 @@ export function SelectSpacesModal({ credentials: "include", }) if (!res.ok) { - if (res.status === 403) { - throw new Error( - "Plugin access was denied. Check your plan or try again.", - ) - } const errorData = (await res.json().catch(() => ({}))) as { message?: string } diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index b46da2c2d..b8e366a40 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -137,6 +137,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [ features: [ "Pay-as-you-go after $5 runs out", "Full search and memory access", + "All plugins (Claude Code, Cursor, Hermes...)", "Email support", ], }, @@ -151,7 +152,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [ features: [ "Auto top-up when balance runs low", "Google Drive, Notion, OneDrive & Granola connectors", - "All plugins (Claude Code, Cursor, Hermes...)", "Priority support", ], },