supermemory/apps/web/app/auth/connect/page.tsx
vorflux[bot] 7c9f2efc4c
fix: redirect new users to onboarding from plugin connect page (#908)
Co-authored-by: Vorflux AI <noreply@vorflux.com>
Co-authored-by: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com>
2026-05-11 10:57:43 -07:00

477 lines
14 KiB
TypeScript

"use client"
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 Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
function isValidLocalhostCallback(callback: string): boolean {
try {
const url = new URL(callback)
const isLocalhost =
url.hostname === "localhost" || url.hostname === "127.0.0.1"
const isHttp = url.protocol === "http:"
const isCallbackPath = url.pathname === "/callback"
return isLocalhost && isHttp && isCallbackPath
} catch {
return false
}
}
interface PluginInfo {
name: string
description: string
features: string[]
icon: string
}
const PLUGIN_INFO: Record<string, PluginInfo> = {
claude_code: {
name: "Claude Code",
description:
"Persistent memory for Claude Code. Remembers your coding context, patterns, and decisions across sessions.",
features: [
"Auto-recalls relevant context at session start",
"Captures important observations from tool usage",
"Builds persistent user profile from interactions",
],
icon: "/images/plugins/claude-code.svg",
},
opencode: {
name: "OpenCode",
description:
"Memory layer for OpenCode. Enhances your coding assistant with long-term memory capabilities.",
features: [
"Semantic search across previous sessions",
"Auto-capture of coding decisions",
"Context injection before each prompt",
],
icon: "/images/plugins/opencode.svg",
},
openclaw: {
name: "OpenClaw",
description:
"Multi-platform memory for OpenClaw. Works across Telegram, WhatsApp, Discord, Slack and more.",
features: [
"Cross-channel memory persistence",
"Automatic conversation capture",
"User profile building across platforms",
],
icon: "/images/plugins/openclaw.svg",
},
hermes: {
name: "Hermes",
description: "Memory layer for Hermes agent",
features: [
"Semantic search across previous sessions",
"Auto-capture of conversation context",
"Builds persistent user profile from interactions",
],
icon: "/images/plugins/hermes.svg",
},
cursor: {
name: "Cursor",
description:
"Memory layer for Cursor. Enhances your AI coding assistant with persistent context.",
features: [
"Remembers coding patterns across sessions",
"Auto-capture of project decisions",
"Context-aware suggestions",
],
icon: "/images/plugins/cursor.svg",
},
}
function getPluginName(client: string): string {
return PLUGIN_INFO[client]?.name ?? "External Tool"
}
type Status = "loading" | "creating" | "success" | "error" | "upgrade"
const pageWrapperClass =
"flex items-center justify-center min-h-screen bg-background p-4"
const cardClass = cn(
"bg-[#14161A] rounded-[14px] p-6 w-full max-w-[400px]",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)
function AuthConnectContent() {
const params = useSearchParams()
const router = useRouter()
const { data: session, isPending } = useSession()
const { org, organizations, isRestoring } = useAuth()
const autumn = useCustomer()
const [status, setStatus] = useState<Status>("loading")
const [error, setError] = useState<string | null>(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
// Redirect new users (logged in but no organization) to onboarding.
// Store the current connect URL so onboarding can redirect back here.
const shouldRedirectToOnboarding =
!isPending &&
!isRestoring &&
!!session &&
Array.isArray(organizations) &&
organizations.length === 0
useEffect(() => {
if (isPending || isRestoring) return
if (!session) return
if (organizations === null) return // orgs query still pending
if (organizations.length > 0) return // has orgs, nothing to do
try {
sessionStorage.setItem(PENDING_CONNECT_URL_KEY, window.location.href)
} catch (e) {
console.warn("Failed to access sessionStorage for pending connect URL", e)
}
router.replace("/onboarding")
}, [isPending, isRestoring, session, organizations, router])
async function handleConnect() {
if (!callback) {
setStatus("error")
setError("Missing callback parameter.")
return
}
if (!isValidLocalhostCallback(callback)) {
setStatus("error")
setError("Invalid callback URL.")
return
}
if (!session || !org) {
setStatus("error")
setError(
"Your account is not fully set up yet. Please complete onboarding first.",
)
return
}
try {
setStatus("creating")
const fetchParams = new URLSearchParams({ callback })
if (validClient) fetchParams.set("client", validClient)
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
}
throw new Error(errorData.message || "Failed to get API key")
}
const data = (await res.json()) as { key: string }
setStatus("success")
const redirectUrl = new URL(callback)
redirectUrl.searchParams.set("apikey", data.key)
window.location.href = redirectUrl.toString()
} catch (err) {
console.error("Failed to get API key:", err)
setStatus("error")
setError(err instanceof Error ? err.message : "Failed to get API key")
}
}
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
if (isAuthLoading || shouldRedirectToOnboarding) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (status === "loading") {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<div className="text-center">
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
Connect {displayName}
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{pluginInfo?.description ??
`Allow ${displayName} to access your Supermemory account.`}
</p>
</div>
{pluginInfo && (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName(
"text-[13px] text-[#8B8B8B]",
)}
>
{feature}
</span>
</li>
))}
</ul>
)}
<button
type="button"
onClick={handleConnect}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
Approve Connection
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
</div>
</div>
</div>
)
}
if (status === "upgrade") {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<div className="text-center">
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
{pluginInfo?.name ?? displayName}
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{pluginInfo?.description ??
`A paid plan is required to use ${displayName} with Supermemory.`}
</p>
</div>
{pluginInfo && (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName(
"text-[13px] text-[#8B8B8B]",
)}
>
{feature}
</span>
</li>
))}
</ul>
)}
<button
type="button"
onClick={handleUpgrade}
disabled={isUpgrading || autumn.isLoading}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading || autumn.isLoading ? (
<>
<Loader className="size-4 animate-spin mr-2" />
Upgrading
</>
) : (
"Upgrade to Pro \u2014 $19/month"
)}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
<a
href="https://app.supermemory.ai/?view=plugins"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
>
View all plans
</a>
</div>
</div>
</div>
)
}
if (status === "error") {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-4 text-center">
<XCircle className="size-10 text-red-400" />
<div>
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
Connection failed
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{error}
</p>
</div>
<div className="flex flex-col gap-2 w-full">
<button
type="button"
onClick={() => window.location.reload()}
className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",
"text-[13px] font-medium cursor-pointer transition-colors hover:bg-[#1E293B]",
dmSans125ClassName(),
)}
>
Try again
</button>
<a
href="https://app.supermemory.ai"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
>
Go to app
</a>
</div>
</div>
</div>
</div>
)
}
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="flex flex-col items-center gap-3">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
<p className={dmSans125ClassName("text-sm text-[#737373]")}>
{status === "creating" && `Connecting ${displayName}`}
{status === "success" &&
`Success! Redirecting back to ${displayName}`}
</p>
</div>
</div>
)
}
export default function AuthConnectPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
</div>
}
>
<AuthConnectContent />
</Suspense>
)
}