feat: add mcp migrate route (#410)

This commit is contained in:
CodeWithShreyans 2025-09-04 16:46:47 +00:00
parent 101f3817bd
commit 84fea4a698
No known key found for this signature in database
GPG key ID: A1B43A0A7B74CD2C
6 changed files with 793 additions and 378 deletions

View file

@ -1,14 +1,14 @@
"use client";
"use client"
import { useIsMobile } from "@hooks/use-mobile";
import { useAuth } from "@lib/auth-context";
import { $fetch } from "@repo/lib/api";
import { MemoryGraph } from "@repo/ui/memory-graph";
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Logo, LogoFull } from "@ui/assets/Logo";
import { Button } from "@ui/components/button";
import { GlassMenuEffect } from "@ui/other/glass-effect";
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import { MemoryGraph } from "@repo/ui/memory-graph"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { Logo, LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import {
HelpCircle,
LayoutGrid,
@ -16,59 +16,59 @@ import {
LoaderIcon,
MessageSquare,
Unplug,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { z } from "zod";
import { ConnectAIModal } from "@/components/connect-ai-modal";
import { InstallPrompt } from "@/components/install-prompt";
import { MemoryListView } from "@/components/memory-list-view";
import Menu from "@/components/menu";
import { ProjectSelector } from "@/components/project-selector";
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal";
import type { TourStep } from "@/components/tour";
import { TourAlertDialog, useTour } from "@/components/tour";
import { AddMemoryView } from "@/components/views/add-memory";
import { ChatRewrite } from "@/components/views/chat";
import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants";
import { useViewMode } from "@/lib/view-mode-context";
import { useChatOpen, useProject } from "@/stores";
import { useGraphHighlights } from "@/stores/highlights";
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { InstallPrompt } from "@/components/install-prompt"
import { MemoryListView } from "@/components/memory-list-view"
import Menu from "@/components/menu"
import { ProjectSelector } from "@/components/project-selector"
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"
import type { TourStep } from "@/components/tour"
import { TourAlertDialog, useTour } from "@/components/tour"
import { AddMemoryView } from "@/components/views/add-memory"
import { ChatRewrite } from "@/components/views/chat"
import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants"
import { useViewMode } from "@/lib/view-mode-context"
import { useChatOpen, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse["documents"][0];
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const MemoryGraphPage = () => {
const { documentIds: allHighlightDocumentIds } = useGraphHighlights();
const isMobile = useIsMobile();
const { viewMode, setViewMode, isInitialized } = useViewMode();
const { selectedProject } = useProject();
const { setSteps, isTourCompleted } = useTour();
const { isOpen, setIsOpen } = useChatOpen();
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([]);
const [showAddMemoryView, setShowAddMemoryView] = useState(false);
const [showReferralModal, setShowReferralModal] = useState(false);
const [showConnectAIModal, setShowConnectAIModal] = useState(false);
const [isHelpHovered, setIsHelpHovered] = useState(false);
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const isMobile = useIsMobile()
const { viewMode, setViewMode, isInitialized } = useViewMode()
const { selectedProject } = useProject()
const { setSteps, isTourCompleted } = useTour()
const { isOpen, setIsOpen } = useChatOpen()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showReferralModal, setShowReferralModal] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const [isHelpHovered, setIsHelpHovered] = useState(false)
// Fetch projects meta to detect experimental flag
const { data: projectsMeta = [] } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects");
return response.data?.projects ?? [];
const response = await $fetch("@get/projects")
return response.data?.projects ?? []
},
staleTime: 5 * 60 * 1000,
});
})
const isCurrentProjectExperimental = !!projectsMeta.find(
(p: any) => p.containerTag === selectedProject,
)?.isExperimental;
)?.isExperimental
// Tour state
const [showTourDialog, setShowTourDialog] = useState(false);
const [showTourDialog, setShowTourDialog] = useState(false)
// Define tour steps with useMemo to prevent recreation
const tourSteps: TourStep[] = useMemo(() => {
@ -201,37 +201,37 @@ const MemoryGraphPage = () => {
selectorId: TOUR_STEP_IDS.FLOATING_CHAT,
position: "left",
},
];
}, []);
]
}, [])
// Check if tour has been completed before
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true";
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
if (!hasCompletedTour && !isTourCompleted) {
const timer = setTimeout(() => {
setShowTourDialog(true);
setShowConnectAIModal(false);
}, 1000); // Show after 1 second
return () => clearTimeout(timer);
setShowTourDialog(true)
setShowConnectAIModal(false)
}, 1000) // Show after 1 second
return () => clearTimeout(timer)
}
}, [isTourCompleted]);
}, [isTourCompleted])
// Set up tour steps
useEffect(() => {
setSteps(tourSteps);
}, [setSteps, tourSteps]);
setSteps(tourSteps)
}, [setSteps, tourSteps])
// Save tour completion to localStorage
useEffect(() => {
if (isTourCompleted) {
localStorage.setItem(TOUR_STORAGE_KEY, "true");
localStorage.setItem(TOUR_STORAGE_KEY, "true")
}
}, [isTourCompleted]);
}, [isTourCompleted])
// Progressive loading via useInfiniteQuery
const IS_DEV = process.env.NODE_ENV === "development";
const PAGE_SIZE = IS_DEV ? 100 : 100;
const MAX_TOTAL = 1000;
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
const {
data,
@ -253,76 +253,75 @@ const MemoryGraphPage = () => {
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
});
})
if (response.error) {
throw new Error(response.error?.message || "Failed to fetch documents");
throw new Error(response.error?.message || "Failed to fetch documents")
}
return response.data;
return response.data
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce(
(acc, p) => acc + (p.documents?.length ?? 0),
0,
);
if (loaded >= MAX_TOTAL) return undefined;
)
if (loaded >= MAX_TOTAL) return undefined
const { currentPage, totalPages } = lastPage.pagination;
const { currentPage, totalPages } = lastPage.pagination
if (currentPage < totalPages) {
return currentPage + 1;
return currentPage + 1
}
return undefined;
return undefined
},
staleTime: 5 * 60 * 1000,
});
})
const baseDocuments = useMemo(() => {
return (
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
);
}, [data]);
)
}, [data])
const allDocuments = useMemo(() => {
if (injectedDocs.length === 0) return baseDocuments;
const byId = new Map<string, DocumentWithMemories>();
for (const d of injectedDocs) byId.set(d.id, d);
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d);
return Array.from(byId.values());
}, [baseDocuments, injectedDocs]);
if (injectedDocs.length === 0) return baseDocuments
const byId = new Map<string, DocumentWithMemories>()
for (const d of injectedDocs) byId.set(d.id, d)
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d)
return Array.from(byId.values())
}, [baseDocuments, injectedDocs])
const totalLoaded = allDocuments.length;
const hasMore = hasNextPage;
const isLoadingMore = isFetchingNextPage;
const totalLoaded = allDocuments.length
const hasMore = hasNextPage
const isLoadingMore = isFetchingNextPage
const loadMoreDocuments = useCallback(async (): Promise<void> => {
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage();
return;
await fetchNextPage()
return
}
return;
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
// Reset injected docs when project changes
useEffect(() => {
setInjectedDocs([]);
}, [selectedProject]);
setInjectedDocs([])
}, [selectedProject])
// Surgical fetch of missing highlighted documents (customId-based IDs from search)
useEffect(() => {
if (!isOpen) return;
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0)
return;
const present = new Set<string>();
if (!isOpen) return
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return
const present = new Set<string>()
for (const d of [...baseDocuments, ...injectedDocs]) {
if (d.id) present.add(d.id);
if ((d as any).customId) present.add((d as any).customId as string);
if (d.id) present.add(d.id)
if ((d as any).customId) present.add((d as any).customId as string)
}
const missing = allHighlightDocumentIds.filter(
(id: string) => !present.has(id),
);
if (missing.length === 0) return;
let cancelled = false;
)
if (missing.length === 0) return
let cancelled = false
const run = async () => {
try {
const resp = await $fetch("@post/memories/documents/by-ids", {
@ -332,32 +331,32 @@ const MemoryGraphPage = () => {
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
});
if (cancelled || (resp as any)?.error) return;
})
if (cancelled || (resp as any)?.error) return
const extraDocs = (resp as any)?.data?.documents as
| DocumentWithMemories[]
| undefined;
if (!extraDocs || extraDocs.length === 0) return;
| undefined
if (!extraDocs || extraDocs.length === 0) return
setInjectedDocs((prev) => {
const seen = new Set<string>([
...prev.map((d) => d.id),
...baseDocuments.map((d) => d.id),
]);
const merged = [...prev];
])
const merged = [...prev]
for (const doc of extraDocs) {
if (!seen.has(doc.id)) {
merged.push(doc);
seen.add(doc.id);
merged.push(doc)
seen.add(doc.id)
}
}
return merged;
});
return merged
})
} catch {}
};
void run();
}
void run()
return () => {
cancelled = true;
};
cancelled = true
}
}, [
isOpen,
allHighlightDocumentIds.join("|"),
@ -365,39 +364,39 @@ const MemoryGraphPage = () => {
injectedDocs,
selectedProject,
$fetch,
]);
])
// Handle view mode change
const handleViewModeChange = useCallback(
(mode: "graph" | "list") => {
setViewMode(mode);
setViewMode(mode)
},
[setViewMode],
);
)
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true";
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
if (hasCompletedTour && allDocuments.length === 0 && !showTourDialog) {
setShowConnectAIModal(true);
setShowConnectAIModal(true)
} else if (showTourDialog) {
setShowConnectAIModal(false);
setShowConnectAIModal(false)
}
}, [allDocuments.length, showTourDialog]);
}, [allDocuments.length, showTourDialog])
// Prevent body scrolling
useEffect(() => {
document.body.style.overflow = "hidden";
document.body.style.height = "100vh";
document.documentElement.style.overflow = "hidden";
document.documentElement.style.height = "100vh";
document.body.style.overflow = "hidden"
document.body.style.height = "100vh"
document.documentElement.style.overflow = "hidden"
document.documentElement.style.height = "100vh"
return () => {
document.body.style.overflow = "";
document.body.style.height = "";
document.documentElement.style.overflow = "";
document.documentElement.style.height = "";
};
}, []);
document.body.style.overflow = ""
document.body.style.height = ""
document.documentElement.style.overflow = ""
document.documentElement.style.height = ""
}
}, [])
return (
<div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none">
@ -528,9 +527,9 @@ const MemoryGraphPage = () => {
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation();
setShowAddMemoryView(true);
setShowConnectAIModal(false);
e.stopPropagation()
setShowAddMemoryView(true)
setShowConnectAIModal(false)
}}
type="button"
>
@ -584,9 +583,9 @@ const MemoryGraphPage = () => {
<button
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
onClick={(e) => {
e.stopPropagation();
setShowAddMemoryView(true);
setShowConnectAIModal(false);
e.stopPropagation()
setShowAddMemoryView(true)
setShowConnectAIModal(false)
}}
type="button"
>
@ -737,26 +736,26 @@ const MemoryGraphPage = () => {
onClose={() => setShowReferralModal(false)}
/>
</div>
);
};
)
}
// Wrapper component to handle auth and waitlist checks
export default function Page() {
const router = useRouter();
const { user } = useAuth();
const router = useRouter()
const { user } = useAuth()
useEffect(() => {
// save the token for chrome extension
const url = new URL(window.location.href);
const rawToken = url.searchParams.get("token");
const url = new URL(window.location.href)
const rawToken = url.searchParams.get("token")
if (rawToken) {
const encodedToken = encodeURIComponent(rawToken);
window.postMessage({ token: encodedToken }, "*");
url.searchParams.delete("token");
window.history.replaceState({}, "", url.toString());
const encodedToken = encodeURIComponent(rawToken)
window.postMessage({ token: encodedToken }, "*")
url.searchParams.delete("token")
window.history.replaceState({}, "", url.toString())
}
}, []);
}, [])
// Show loading state while checking authentication and waitlist status
if (!user) {
@ -767,7 +766,7 @@ export default function Page() {
<p className="text-white/60">Loading...</p>
</div>
</div>
);
)
}
// If we have a user and they have access, show the main component
@ -776,5 +775,5 @@ export default function Page() {
<MemoryGraphPage />
<InstallPrompt />
</>
);
)
}

View file

@ -0,0 +1,324 @@
"use client"
import { $fetch } from "@lib/api"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import { useMutation } from "@tanstack/react-query"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Spinner } from "@/components/spinner"
import { motion, AnimatePresence } from "motion/react"
import { Logo, LogoFull } from "@ui/assets/Logo"
import { ArrowRight, CheckCircle, Upload, Zap } from "lucide-react"
import Link from "next/link"
import { useSession } from "@lib/auth"
interface MigrateMCPRequest {
userId: string
projectId: string
}
interface MigrateMCPResponse {
success: boolean
migratedCount: number
message: string
documentIds?: string[]
}
export default function MigrateMCPPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [mcpUrl, setMcpUrl] = useState("")
const [projectId, setProjectId] = useState("default")
const session = useSession()
// Extract MCP URL from query parameter
useEffect(() => {
const urlParam = searchParams.get("url")
if (urlParam) {
setMcpUrl(urlParam)
}
}, [searchParams])
useEffect(() => {
console.log("session", session)
if (!session.isPending && !session.data) {
const redirectUrl = new URL("/login", window.location.href)
redirectUrl.searchParams.set("redirect", window.location.href)
router.push(redirectUrl.toString())
return
}
}, [session, router])
// Extract userId from MCP URL
const getUserIdFromUrl = (url: string) => {
return url.split("/").at(-2) || ""
}
const migrateMutation = useMutation({
mutationFn: async (data: MigrateMCPRequest) => {
const response = await $fetch("@post/memories/migrate-mcp", {
body: data,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to migrate documents",
)
}
return response.data
},
onSuccess: (data: MigrateMCPResponse) => {
toast.success("Migration completed successfully", {
description: data.message,
})
// Redirect to home page after successful migration
setTimeout(() => {
router.push("/?open=mcp")
}, 2000) // Wait 2 seconds to show the success message
},
onError: (error: Error) => {
toast.error("Migration failed", {
description: error.message || "An unexpected error occurred",
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const userId = getUserIdFromUrl(mcpUrl)
if (!userId) {
toast.error("Please enter a valid MCP URL")
return
}
migrateMutation.mutate({
userId,
projectId: projectId.trim() || "default",
})
}
return (
<div className="min-h-screen bg-[#0f1419] overflow-hidden relative">
{/* Background elements */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-900/10 via-transparent to-purple-900/10" />
{/* Top navigation */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-0 left-0 right-0 z-10 p-6 flex items-center justify-between"
>
<Link
href="/"
className="pointer-events-auto hover:opacity-80 transition-opacity"
>
<LogoFull className="h-8 hidden md:block text-white" />
<Logo className="h-8 md:hidden text-white" />
</Link>
</motion.div>
{/* Main content */}
<div className="flex items-center justify-center min-h-screen p-4 relative z-10">
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
delay: 0.1,
}}
className="w-full max-w-lg relative"
>
{/* Glass card with effect */}
<div className="relative rounded-2xl overflow-hidden">
<GlassMenuEffect rounded="rounded-2xl" />
<div className="relative z-10 p-8 md:p-10">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-center mb-8"
>
<div className="flex items-center justify-center mb-4">
<div className="relative">
<div className="absolute inset-0 bg-blue-500/20 rounded-full blur-xl" />
<div className="relative bg-blue-500/10 p-3 rounded-full border border-blue-500/20">
<Zap className="w-6 h-6 text-blue-400" />
</div>
</div>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-white mb-2">
Upgrade supermemory MCP
</h1>
<p className="text-slate-400 text-sm md:text-base">
Migrate your documents to the new MCP server
</p>
</motion.div>
{/* Form */}
<motion.form
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
onSubmit={handleSubmit}
className="space-y-6"
>
<div className="space-y-2">
<label
htmlFor="mcpUrl"
className="text-sm font-medium text-slate-200 flex items-center gap-2"
>
<Upload className="w-4 h-4" />
MCP URL
</label>
<div className="relative">
<Input
id="mcpUrl"
type="url"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder="https://mcp.supermemory.ai/userId/sse"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-blue-500/50 focus:ring-blue-500/20 transition-all duration-200 pl-4 pr-4 py-3 rounded-xl"
disabled={migrateMutation.isPending}
/>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="projectId"
className="text-sm font-medium text-slate-200"
>
Project ID
</label>
<div className="relative">
<Input
id="projectId"
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Project ID (default: 'default')"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-blue-500/50 focus:ring-blue-500/20 transition-all duration-200 pl-4 pr-4 py-3 rounded-xl"
disabled={migrateMutation.isPending}
/>
</div>
</div>
<motion.div
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white border-0 py-3 rounded-xl font-medium shadow-lg hover:shadow-blue-500/25 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={
migrateMutation.isPending || !getUserIdFromUrl(mcpUrl)
}
size="lg"
>
{migrateMutation.isPending ? (
<>
<Spinner className="mr-2 w-4 h-4" />
Migrating documents...
</>
) : (
<>
Start Upgrade
<ArrowRight className="ml-2 w-4 h-4" />
</>
)}
</Button>
</motion.div>
</motion.form>
{/* Success/Error States */}
<AnimatePresence mode="wait">
{migrateMutation.isSuccess && migrateMutation.data && (
<motion.div
key="success"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="mt-6"
>
<div className="relative rounded-xl overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-green-500/10 to-emerald-500/10" />
<div className="relative p-4 border border-green-500/20 rounded-xl">
<div className="text-green-400">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-5 h-5" />
<p className="font-medium">
Migration completed successfully!
</p>
</div>
<p className="text-sm text-green-300/80 mb-3">
Migrated {migrateMutation.data.migratedCount}{" "}
documents
</p>
{migrateMutation.data.documentIds &&
migrateMutation.data.documentIds.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer hover:text-green-300 transition-colors text-sm font-medium">
View migrated document IDs
</summary>
<div className="mt-3 space-y-2 max-h-40 overflow-y-auto">
{migrateMutation.data.documentIds.map(
(id) => (
<code
key={id}
className="block text-xs bg-black/30 px-3 py-2 rounded-lg border border-green-500/10 text-green-200"
>
{id}
</code>
),
)}
</div>
</details>
)}
</div>
</div>
</div>
</motion.div>
)}
{migrateMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="mt-6"
>
<div className="relative rounded-xl overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-red-500/10 to-pink-500/10" />
<div className="relative p-4 border border-red-500/20 rounded-xl">
<div className="text-red-400">
<p className="font-medium mb-1">Migration failed</p>
<p className="text-sm text-red-300/80">
{migrateMutation.error?.message ||
"An unexpected error occurred"}
</p>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
</div>
</div>
)
}

View file

@ -1,27 +1,28 @@
"use client";
"use client"
import { useIsMobile } from "@hooks/use-mobile";
import { useIsMobile } from "@hooks/use-mobile"
import {
fetchConsumerProProduct,
fetchMemoriesFeature,
} from "@repo/lib/queries";
import { Button } from "@repo/ui/components/button";
import { ConnectAIModal } from "./connect-ai-modal";
import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold";
import { GlassMenuEffect } from "@ui/other/glass-effect";
import { useCustomer } from "autumn-js/react";
import { MessageSquareMore, Plus, Puzzle, User, X } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useState } from "react";
import { Drawer } from "vaul";
import { useMobilePanel } from "@/lib/mobile-panel-context";
import { TOUR_STEP_IDS } from "@/lib/tour-constants";
import { useChatOpen } from "@/stores";
import { ProjectSelector } from "./project-selector";
import { useTour } from "./tour";
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory";
import { IntegrationsView } from "./views/integrations";
import { ProfileView } from "./views/profile";
} from "@repo/lib/queries"
import { Button } from "@repo/ui/components/button"
import { ConnectAIModal } from "./connect-ai-modal"
import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import { useCustomer } from "autumn-js/react"
import { MessageSquareMore, Plus, Puzzle, User, X } from "lucide-react"
import { AnimatePresence, LayoutGroup, motion } from "motion/react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { Drawer } from "vaul"
import { useMobilePanel } from "@/lib/mobile-panel-context"
import { TOUR_STEP_IDS } from "@/lib/tour-constants"
import { useChatOpen } from "@/stores"
import { ProjectSelector } from "./project-selector"
import { useTour } from "./tour"
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
import { IntegrationsView } from "./views/integrations"
import { ProfileView } from "./views/profile"
const MCPIcon = ({ className }: { className?: string }) => {
return (
@ -36,45 +37,67 @@ const MCPIcon = ({ className }: { className?: string }) => {
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
);
};
)
}
function Menu({ id }: { id?: string }) {
const [isHovered, setIsHovered] = useState(false);
const router = useRouter()
const searchParams = useSearchParams()
const openParam = searchParams.get("open")
// Valid view names that can be opened via URL parameter
const validViews = [
"addUrl",
"mcp",
"projects",
"profile",
"integrations",
] as const
type ValidView = (typeof validViews)[number]
const [isHovered, setIsHovered] = useState(false)
const [expandedView, setExpandedView] = useState<
"addUrl" | "mcp" | "projects" | "profile" | "integrations" | null
>(null);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isCollapsing, setIsCollapsing] = useState(false);
const [showAddMemoryView, setShowAddMemoryView] = useState(false);
const [showConnectAIModal, setShowConnectAIModal] = useState(false);
const isMobile = useIsMobile();
const { activePanel, setActivePanel } = useMobilePanel();
const { setMenuExpanded } = useTour();
const autumn = useCustomer();
const { setIsOpen } = useChatOpen();
>(null)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isCollapsing, setIsCollapsing] = useState(false)
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()
const { activePanel, setActivePanel } = useMobilePanel()
const { setMenuExpanded } = useTour()
const autumn = useCustomer()
const { setIsOpen } = useChatOpen()
const { data: memoriesCheck } = fetchMemoriesFeature(autumn);
const { data: memoriesCheck } = fetchMemoriesFeature(autumn)
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
const { data: proCheck } = fetchConsumerProProduct(autumn);
const { data: proCheck } = fetchConsumerProProduct(autumn)
useEffect(() => {
if (memoriesCheck) {
console.log({ memoriesCheck });
console.log({ memoriesCheck })
}
if (proCheck) {
console.log({ proCheck });
console.log({ proCheck })
}
}, [memoriesCheck, proCheck]);
}, [memoriesCheck, proCheck])
const isProUser = proCheck?.allowed ?? false;
// Function to clear the 'open' parameter from URL
const clearOpenParam = useCallback(() => {
const newSearchParams = new URLSearchParams(searchParams.toString())
newSearchParams.delete("open")
const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`
router.replace(newUrl)
}, [searchParams, router])
const isProUser = proCheck?.allowed ?? false
const shouldShowLimitWarning =
!isProUser && memoriesUsed >= memoriesLimit * 0.8;
!isProUser && memoriesUsed >= memoriesLimit * 0.8
// Map menu item keys to tour IDs
const menuItemTourIds: Record<string, string> = {
@ -82,7 +105,7 @@ function Menu({ id }: { id?: string }) {
projects: TOUR_STEP_IDS.MENU_PROJECTS,
mcp: TOUR_STEP_IDS.MENU_MCP,
integrations: "", // No tour ID for integrations yet
};
}
const menuItems = [
{
@ -115,61 +138,102 @@ function Menu({ id }: { id?: string }) {
key: "profile" as const,
disabled: false,
},
];
]
const handleMenuItemClick = (
key: "chat" | "addUrl" | "mcp" | "projects" | "profile" | "integrations",
) => {
if (key === "chat") {
setIsOpen(true);
setIsMobileMenuOpen(false);
setIsOpen(true)
setIsMobileMenuOpen(false)
if (isMobile) {
setActivePanel("chat");
setActivePanel("chat")
}
} else if (key === "mcp") {
// Open ConnectAIModal directly for MCP
setIsMobileMenuOpen(false);
setExpandedView(null);
setShowConnectAIModal(true);
setIsMobileMenuOpen(false)
setExpandedView(null)
setShowConnectAIModal(true)
} else {
if (expandedView === key) {
setIsCollapsing(true);
setExpandedView(null);
setIsCollapsing(true)
setExpandedView(null)
} else if (key === "addUrl") {
setShowAddMemoryView(true);
setExpandedView(null);
setShowAddMemoryView(true)
setExpandedView(null)
} else {
setExpandedView(key);
setExpandedView(key)
}
if (isMobile) {
setActivePanel("menu");
setActivePanel("menu")
}
}
};
}
// Handle initial view opening based on URL parameter
useEffect(() => {
if (openParam) {
if (openParam === "chat") {
setIsOpen(true)
setIsMobileMenuOpen(false)
if (isMobile) {
setActivePanel("chat")
}
} else if (openParam === "mcp") {
// Open ConnectAIModal directly for MCP
setIsMobileMenuOpen(false)
setExpandedView(null)
setShowConnectAIModal(true)
} else if (openParam === "addUrl") {
setShowAddMemoryView(true)
setExpandedView(null)
if (isMobile) {
setIsMobileMenuOpen(true)
setActivePanel("menu")
}
} else if (validViews.includes(openParam as ValidView)) {
// For other valid views like "profile", "integrations"
setExpandedView(openParam as ValidView)
if (isMobile) {
setIsMobileMenuOpen(true)
setActivePanel("menu")
}
}
// Clear the parameter from URL after performing any action
clearOpenParam()
}
}, [
openParam,
isMobile,
setIsOpen,
setActivePanel,
validViews,
clearOpenParam,
])
// Watch for active panel changes on mobile
useEffect(() => {
if (isMobile && activePanel !== "menu" && activePanel !== null) {
// Another panel became active, close the menu
setIsMobileMenuOpen(false);
setExpandedView(null);
setIsMobileMenuOpen(false)
setExpandedView(null)
}
}, [isMobile, activePanel]);
}, [isMobile, activePanel])
// Notify tour provider about expansion state changes
useEffect(() => {
const isExpanded = isMobile
? isMobileMenuOpen || !!expandedView
: isHovered || !!expandedView;
setMenuExpanded(isExpanded);
}, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]);
: isHovered || !!expandedView
setMenuExpanded(isExpanded)
}, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded])
// Calculate width based on state
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56;
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56
// Dynamic z-index for mobile based on active panel
const mobileZIndex =
isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]";
const mobileZIndex = isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]"
return (
<>
@ -378,8 +442,8 @@ function Menu({ id }: { id?: string }) {
<Button
className="text-white/70 hover:text-white transition-colors duration-200"
onClick={() => {
setIsCollapsing(true);
setExpandedView(null);
setIsCollapsing(true)
setExpandedView(null)
}}
size="icon"
variant="ghost"
@ -418,9 +482,9 @@ function Menu({ id }: { id?: string }) {
open={isMobileMenuOpen || !!expandedView}
onOpenChange={(open) => {
if (!open) {
setIsMobileMenuOpen(false);
setExpandedView(null);
setActivePanel(null);
setIsMobileMenuOpen(false)
setExpandedView(null)
setActivePanel(null)
}
}}
>
@ -433,8 +497,8 @@ function Menu({ id }: { id?: string }) {
className="w-14 h-14 flex items-center justify-center text-white rounded-full shadow-2xl"
initial={{ scale: 0.8, opacity: 0 }}
onClick={() => {
setIsMobileMenuOpen(true);
setActivePanel("menu");
setIsMobileMenuOpen(true)
setActivePanel("menu")
}}
transition={{
duration: 0.3,
@ -530,13 +594,13 @@ function Menu({ id }: { id?: string }) {
initial={{ opacity: 0, y: 10 }}
layout
onClick={() => {
handleMenuItemClick(item.key);
handleMenuItemClick(item.key)
if (
item.key !== "mcp" &&
item.key !== "profile" &&
item.key !== "integrations"
) {
setIsMobileMenuOpen(false);
setIsMobileMenuOpen(false)
}
}}
type="button"
@ -600,8 +664,8 @@ function Menu({ id }: { id?: string }) {
<Button
className="text-white/70 hover:text-white transition-colors duration-200"
onClick={() => {
setIsCollapsing(true);
setExpandedView(null);
setIsCollapsing(true)
setExpandedView(null)
}}
size="icon"
variant="ghost"
@ -643,7 +707,7 @@ function Menu({ id }: { id?: string }) {
<Button className="hidden">Connect AI Assistant</Button>
</ConnectAIModal>
</>
);
)
}
export default Menu;
export default Menu

View file

@ -1,15 +1,15 @@
"use client";
"use client"
import { authClient } from "@lib/auth";
import { useAuth } from "@lib/auth-context";
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import {
fetchConnectionsFeature,
fetchMemoriesFeature,
fetchSubscriptionStatus,
} from "@lib/queries";
import { Button } from "@repo/ui/components/button";
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold";
import { useCustomer } from "autumn-js/react";
} from "@lib/queries"
import { Button } from "@repo/ui/components/button"
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
import { useCustomer } from "autumn-js/react"
import {
CheckCircle,
CreditCard,
@ -17,27 +17,27 @@ import {
LogOut,
User,
X,
} from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { analytics } from "@/lib/analytics";
} from "lucide-react"
import { motion } from "motion/react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { analytics } from "@/lib/analytics"
export function ProfileView() {
const router = useRouter();
const pathname = usePathname();
const { user: session, org } = useAuth();
const organizations = org;
const autumn = useCustomer();
const [isLoading, setIsLoading] = useState(false);
const router = useRouter()
const pathname = usePathname()
const { user: session, org } = useAuth()
const organizations = org
const autumn = useCustomer()
const [isLoading, setIsLoading] = useState(false)
const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any);
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any);
const connectionsUsed = connectionsCheck?.usage ?? 0;
const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any)
const connectionsUsed = connectionsCheck?.usage ?? 0
// Fetch subscription status with React Query
const {
@ -45,36 +45,36 @@ export function ProfileView() {
consumer_pro: null,
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn as any);
} = fetchSubscriptionStatus(autumn as any)
const isPro = status.consumer_pro;
const isPro = status.consumer_pro
const handleLogout = () => {
analytics.userSignedOut();
authClient.signOut();
router.push("/login");
};
analytics.userSignedOut()
authClient.signOut()
router.push("/login")
}
const handleUpgrade = async () => {
setIsLoading(true);
setIsLoading(true)
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
});
window.location.reload();
})
window.location.reload()
} catch (error) {
console.error(error);
setIsLoading(false);
console.error(error)
setIsLoading(false)
}
};
}
// Handle manage billing
const handleManageBilling = async () => {
await autumn.openBillingPortal({
returnUrl: "https://app.supermemory.ai",
});
};
})
}
if (session?.isAnonymous) {
return (
@ -99,7 +99,7 @@ export function ProfileView() {
</motion.div>
</motion.div>
</div>
);
)
}
return (
@ -282,5 +282,5 @@ export function ProfileView() {
Sign Out
</Button>
</div>
);
)
}

View file

@ -1,29 +1,30 @@
import { $fetch } from "@lib/api";
import { getSessionCookie } from "better-auth/cookies";
import { NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies"
import { NextResponse } from "next/server"
export default async function middleware(request: Request) {
console.debug("[MIDDLEWARE] === MIDDLEWARE START ===");
const url = new URL(request.url);
console.debug("[MIDDLEWARE] Path:", url.pathname);
console.debug("[MIDDLEWARE] Method:", request.method);
console.debug("[MIDDLEWARE] === MIDDLEWARE START ===")
const url = new URL(request.url)
console.debug("[MIDDLEWARE] Path:", url.pathname)
console.debug("[MIDDLEWARE] Method:", request.method)
const sessionCookie = getSessionCookie(request);
console.debug("[MIDDLEWARE] Session cookie exists:", !!sessionCookie);
const sessionCookie = getSessionCookie(request)
console.debug("[MIDDLEWARE] Session cookie exists:", !!sessionCookie)
// Always allow access to login and waitlist pages
const publicPaths = ["/login"];
const publicPaths = ["/login"]
if (publicPaths.includes(url.pathname)) {
console.debug("[MIDDLEWARE] Public path, allowing access");
return NextResponse.next();
console.debug("[MIDDLEWARE] Public path, allowing access")
return NextResponse.next()
}
// If no session cookie and not on a public path, redirect to login
if (!sessionCookie) {
console.debug(
"[MIDDLEWARE] No session cookie and not on public path, redirecting to /login",
);
return NextResponse.redirect(new URL("/login", request.url));
)
const url = new URL("/login", request.url)
url.searchParams.set("redirect", request.url)
return NextResponse.redirect(url)
}
// TEMPORARILY DISABLED: Waitlist check
@ -39,13 +40,13 @@ export default async function middleware(request: Request) {
// }
// }
console.debug("[MIDDLEWARE] Passing through to next handler");
console.debug("[MIDDLEWARE] === MIDDLEWARE END ===");
return NextResponse.next();
console.debug("[MIDDLEWARE] Passing through to next handler")
console.debug("[MIDDLEWARE] === MIDDLEWARE END ===")
return NextResponse.next()
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|ingest|api|login|api/emails).*)",
],
};
}

View file

@ -1,26 +1,26 @@
"use client"
"use client";
import { signIn } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { LogoFull } from "@repo/ui/assets/Logo"
import { TextSeparator } from "@repo/ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"
import { signIn } from "@lib/auth";
import { usePostHog } from "@lib/posthog";
import { LogoFull } from "@repo/ui/assets/Logo";
import { TextSeparator } from "@repo/ui/components/text-separator";
import { ExternalAuthButton } from "@ui/button/external-auth";
import { Button } from "@ui/components/button";
import { Badge } from "@ui/components/badge";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@ui/components/carousel"
import { LabeledInput } from "@ui/input/labeled-input"
import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium"
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
import { Label1Regular } from "@ui/text/label/label-1-regular"
import { Title1Bold } from "@ui/text/title/title-1-bold"
import Autoplay from "embla-carousel-autoplay"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useEffect } from "react"
} from "@ui/components/carousel";
import { LabeledInput } from "@ui/input/labeled-input";
import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium";
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium";
import { Label1Regular } from "@ui/text/label/label-1-regular";
import { Title1Bold } from "@ui/text/title/title-1-bold";
import Autoplay from "embla-carousel-autoplay";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
export function LoginPage({
heroText = "The unified memory API for the AI era.",
@ -29,101 +29,122 @@ export function LoginPage({
"Trusted by Open Source, enterprise and developers.",
],
}) {
const [email, setEmail] = useState("")
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
const [error, setError] = useState<string | null>(null)
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
const router = useRouter()
const [email, setEmail] = useState("");
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingEmail, setIsLoadingEmail] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null);
const router = useRouter();
const posthog = usePostHog()
const posthog = usePostHog();
const params = useSearchParams()
const params = useSearchParams();
// Get redirect URL from query params
const redirectUrl = params.get("redirect");
// Create callback URL that includes redirect parameter if provided
const getCallbackURL = () => {
const origin = window.location.origin;
if (redirectUrl) {
// Validate that the redirect URL is safe (same origin or allow external based on your security requirements)
try {
const url = new URL(redirectUrl, origin);
return url.toString();
} catch {
// If redirect URL is invalid, fall back to origin
return origin;
}
}
return origin;
};
// Load last used method from localStorage on mount
useEffect(() => {
const savedMethod = localStorage.getItem('supermemory-last-login-method')
setLastUsedMethod(savedMethod)
}, [])
const savedMethod = localStorage.getItem("supermemory-last-login-method");
setLastUsedMethod(savedMethod);
}, []);
// Record the pending login method (will be committed after successful auth)
function setPendingLoginMethod(method: string) {
try {
localStorage.setItem('supermemory-pending-login-method', method)
localStorage.setItem('supermemory-pending-login-timestamp', String(Date.now()))
} catch { }
localStorage.setItem("supermemory-pending-login-method", method);
localStorage.setItem(
"supermemory-pending-login-timestamp",
String(Date.now()),
);
} catch {}
}
// If we land back on this page with an error, clear any pending marker
useEffect(() => {
if (params.get("error")) {
try {
localStorage.removeItem('supermemory-pending-login-method')
localStorage.removeItem('supermemory-pending-login-timestamp')
} catch { }
localStorage.removeItem("supermemory-pending-login-method");
localStorage.removeItem("supermemory-pending-login-timestamp");
} catch {}
}
}, [params])
}, [params]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setIsLoadingEmail(true)
setError(null)
e.preventDefault();
setIsLoading(true);
setIsLoadingEmail(true);
setError(null);
// Track login attempt
posthog.capture("login_attempt", {
method: "magic_link",
email_domain: email.split("@")[1] || "unknown",
})
});
try {
await signIn.magicLink({
callbackURL: window.location.origin,
callbackURL: getCallbackURL(),
email,
})
setSubmittedEmail(email)
setPendingLoginMethod('magic_link')
});
setSubmittedEmail(email);
setPendingLoginMethod("magic_link");
// Track successful magic link send
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
});
} catch (error) {
console.error(error)
console.error(error);
// Track login failure
posthog.capture("login_failed", {
method: "magic_link",
error: error instanceof Error ? error.message : "Unknown error",
email_domain: email.split("@")[1] || "unknown",
})
});
setError(
error instanceof Error
? error.message
: "Failed to send login link. Please try again.",
)
setIsLoading(false)
setIsLoadingEmail(false)
return
);
setIsLoading(false);
setIsLoadingEmail(false);
return;
}
setIsLoading(false)
setIsLoadingEmail(false)
}
setIsLoading(false);
setIsLoadingEmail(false);
};
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
event.preventDefault();
setIsLoading(true);
const formData = new FormData(event.currentTarget)
const token = formData.get("token") as string
const formData = new FormData(event.currentTarget);
const token = formData.get("token") as string;
const callbackURL = getCallbackURL();
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(window.location.host)}`,
)
}
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
);
};
return (
<section className="min-h-screen flex flex-col lg:grid lg:grid-cols-12 items-center justify-center p-4 sm:p-6 md:p-8 lg:px-[5rem] lg:py-[3.125rem] gap-6 lg:gap-[5rem] max-w-[400rem] mx-auto">
@ -225,8 +246,8 @@ export function LoginPage({
disabled: isLoading,
id: "email",
onChange: (e) => {
setEmail(e.target.value)
error && setError(null)
setEmail(e.target.value);
error && setError(null);
},
required: true,
value: email,
@ -241,9 +262,11 @@ export function LoginPage({
? "Sending login link..."
: "Log in to supermemory"}
</Button>
{lastUsedMethod === 'magic_link' && (
{lastUsedMethod === "magic_link" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">Last used</Badge>
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
@ -251,14 +274,14 @@ export function LoginPage({
</form>
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<TextSeparator text="OR" />
) : null}
<div className="flex flex-col sm:flex-row flex-wrap gap-3 lg:gap-4">
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
<div className="relative flex-grow">
<ExternalAuthButton
authIcon={
@ -293,32 +316,34 @@ export function LoginPage({
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
if (isLoading) return;
setIsLoading(true);
posthog.capture("login_attempt", {
method: "social",
provider: "google",
})
setPendingLoginMethod('google')
});
setPendingLoginMethod("google");
signIn
.social({
callbackURL: window.location.origin,
callbackURL: getCallbackURL(),
provider: "google",
})
.finally(() => {
setIsLoading(false)
})
setIsLoading(false);
});
}}
/>
{lastUsedMethod === 'google' && (
{lastUsedMethod === "google" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">Last used</Badge>
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
<div className="relative flex-grow">
<ExternalAuthButton
authIcon={
@ -355,26 +380,28 @@ export function LoginPage({
className="w-full"
disabled={isLoading}
onClick={() => {
if (isLoading) return
setIsLoading(true)
if (isLoading) return;
setIsLoading(true);
posthog.capture("login_attempt", {
method: "social",
provider: "github",
})
setPendingLoginMethod('github')
});
setPendingLoginMethod("github");
signIn
.social({
callbackURL: window.location.origin,
callbackURL: getCallbackURL(),
provider: "github",
})
.finally(() => {
setIsLoading(false)
})
setIsLoading(false);
});
}}
/>
{lastUsedMethod === 'github' && (
{lastUsedMethod === "github" && (
<div className="absolute -top-2 -right-2">
<Badge variant="default" className="text-xs">Last used</Badge>
<Badge variant="default" className="text-xs">
Last used
</Badge>
</div>
)}
</div>
@ -403,5 +430,5 @@ export function LoginPage({
</div>
)}
</section>
)
}
);
}