diff options
| -rw-r--r-- | apps/web/app/page.tsx | 299 | ||||
| -rw-r--r-- | apps/web/app/upgrade-mcp/page.tsx | 324 | ||||
| -rw-r--r-- | apps/web/components/menu.tsx | 222 | ||||
| -rw-r--r-- | apps/web/components/views/profile.tsx | 80 | ||||
| -rw-r--r-- | apps/web/middleware.ts | 37 | ||||
| -rw-r--r-- | packages/ui/pages/login.tsx | 213 |
6 files changed, 795 insertions, 380 deletions
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 5018c337..6bf8cf6f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,14 +1,14 @@ -"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"; +"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 { 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"; - -type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>; -type DocumentWithMemories = DocumentsResponse["documents"][0]; +} 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] 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 /> </> - ); + ) } diff --git a/apps/web/app/upgrade-mcp/page.tsx b/apps/web/app/upgrade-mcp/page.tsx new file mode 100644 index 00000000..baffcbd2 --- /dev/null +++ b/apps/web/app/upgrade-mcp/page.tsx @@ -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> + ) +} diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index 6eb81128..db012ab7 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -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 diff --git a/apps/web/components/views/profile.tsx b/apps/web/components/views/profile.tsx index 336b9416..b19706f7 100644 --- a/apps/web/components/views/profile.tsx +++ b/apps/web/components/views/profile.tsx @@ -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> - ); + ) } diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 71c7fc1d..9862462d 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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).*)", ], -}; +} diff --git a/packages/ui/pages/login.tsx b/packages/ui/pages/login.tsx index fcd48eae..74765da1 100644 --- a/packages/ui/pages/login.tsx +++ b/packages/ui/pages/login.tsx @@ -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> - ) -}
\ No newline at end of file + ); +} |