mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-10 22:41:17 +00:00
fix: redirect new users to onboarding from plugin connect page
New users arriving at /auth/connect (e.g. from OpenCode CLI) had no organization yet, causing the 'Approve Connection' button to silently fail. The handleConnect guard 'if (!session || !org) return' would fire with no feedback, leading to rage-clicking. Changes: - Detect logged-in users with no org on the connect page and redirect them to onboarding, stashing the connect URL in sessionStorage. - After onboarding completes, redirect back to the connect page so the plugin auth flow finishes automatically. - Show an error message instead of silently returning when session/org is missing and the user clicks 'Approve Connection'. - Extract PENDING_CONNECT_URL_KEY to a shared constants file.
This commit is contained in:
parent
805cf3cb93
commit
0f3f17b68c
8 changed files with 199 additions and 12 deletions
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
export const SETUP_STEPS = ["integrations"] as const
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number]
|
||||
|
|
@ -61,7 +62,11 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
|
|||
|
||||
const finishOnboarding = useCallback(() => {
|
||||
resetOnboarding()
|
||||
router.push("/")
|
||||
|
||||
// If the user arrived from the plugin connect page (e.g. OpenCode),
|
||||
// redirect back there so the auth flow can complete automatically.
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}, [router, resetOnboarding])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { Sparkles, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
type DetectedSource = "x" | "linkedin" | "resume" | null
|
||||
type Status = "idle" | "processing" | "done" | "error"
|
||||
|
|
@ -373,6 +374,12 @@ export default function OnboardingPage() {
|
|||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [spotlightCategory, setSpotlightCategory] =
|
||||
useState<SpotlightCategoryId>("productivity")
|
||||
|
||||
/** Navigate home, or back to the plugin connect page if one is pending. */
|
||||
const goHomeOrPendingConnect = useCallback(() => {
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}, [router])
|
||||
const [pauseSpotlight, setPauseSpotlight] = useState(false)
|
||||
|
||||
const spotlightCatalog = useMemo(
|
||||
|
|
@ -620,7 +627,7 @@ export default function OnboardingPage() {
|
|||
<Logo className="h-7" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
onClick={goHomeOrPendingConnect}
|
||||
className="text-[#525966] text-sm hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Skip for now →
|
||||
|
|
@ -1064,7 +1071,7 @@ export default function OnboardingPage() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
onClick={goHomeOrPendingConnect}
|
||||
className="text-sm text-[#3A4A5E] hover:text-[#6B7A8D] transition-colors cursor-pointer"
|
||||
>
|
||||
Go to home
|
||||
|
|
@ -1096,7 +1103,7 @@ export default function OnboardingPage() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
onClick={goHomeOrPendingConnect}
|
||||
className="rounded-xl px-4 py-2.5 text-sm font-medium text-white cursor-pointer border-[0.5px] border-[#161F2C]"
|
||||
style={{
|
||||
background:
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import { dmSans125ClassName } from "@/lib/fonts"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { ArrowRight, Loader, XCircle } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Suspense, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
|
||||
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
|
||||
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
|
@ -105,8 +107,9 @@ const cardClass = cn(
|
|||
|
||||
function AuthConnectContent() {
|
||||
const params = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession()
|
||||
const { org } = useAuth()
|
||||
const { org, organizations, isRestoring } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const [status, setStatus] = useState<Status>("loading")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -118,6 +121,29 @@ function AuthConnectContent() {
|
|||
const displayName = validClient ? getPluginName(validClient) : "External Tool"
|
||||
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null
|
||||
|
||||
// Redirect new users (logged in but no organization) to onboarding.
|
||||
// Store the current connect URL so onboarding can redirect back here.
|
||||
const shouldRedirectToOnboarding =
|
||||
!isPending &&
|
||||
!isRestoring &&
|
||||
!!session &&
|
||||
Array.isArray(organizations) &&
|
||||
organizations.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || isRestoring) return
|
||||
if (!session) return
|
||||
if (organizations === null) return // orgs query still pending
|
||||
if (organizations.length > 0) return // has orgs, nothing to do
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_CONNECT_URL_KEY, window.location.href)
|
||||
} catch (e) {
|
||||
console.warn("Failed to access sessionStorage for pending connect URL", e)
|
||||
}
|
||||
router.replace("/onboarding")
|
||||
}, [isPending, isRestoring, session, organizations, router])
|
||||
|
||||
async function handleConnect() {
|
||||
if (!callback) {
|
||||
setStatus("error")
|
||||
|
|
@ -129,7 +155,13 @@ function AuthConnectContent() {
|
|||
setError("Invalid callback URL.")
|
||||
return
|
||||
}
|
||||
if (!session || !org) return
|
||||
if (!session || !org) {
|
||||
setStatus("error")
|
||||
setError(
|
||||
"Your account is not fully set up yet. Please complete onboarding first.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus("creating")
|
||||
|
|
@ -178,7 +210,10 @@ function AuthConnectContent() {
|
|||
}
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
// Show a spinner while session/org data is loading or while we're about
|
||||
// to redirect to onboarding (prevents a brief flash of the connect card).
|
||||
const isAuthLoading = isPending || isRestoring || organizations === null
|
||||
if (isAuthLoading || shouldRedirectToOnboarding) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button } from "@ui/components/button"
|
|||
import { useRouter } from "next/navigation"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
export function InitialHeader({
|
||||
showUserSupermemory,
|
||||
|
|
@ -22,7 +23,8 @@ export function InitialHeader({
|
|||
const handleSkip = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
export function SetupHeader() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -20,7 +21,8 @@ export function SetupHeader() {
|
|||
const handleSkip = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}
|
||||
|
||||
const displayName =
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { cn } from "@lib/utils"
|
|||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { consumePendingConnectUrl } from "@/lib/constants"
|
||||
|
||||
const integrationCards = [
|
||||
{
|
||||
|
|
@ -67,7 +68,8 @@ export function IntegrationsStep() {
|
|||
const handleContinue = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
const pendingPath = consumePendingConnectUrl()
|
||||
router.push(pendingPath ?? "/")
|
||||
}
|
||||
|
||||
if (selectedCard === "Connect to AI") {
|
||||
|
|
|
|||
106
apps/web/lib/__tests__/constants.test.ts
Normal file
106
apps/web/lib/__tests__/constants.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { PENDING_CONNECT_URL_KEY, consumePendingConnectUrl } from "../constants"
|
||||
|
||||
// Minimal sessionStorage mock for Node environment
|
||||
function createMockSessionStorage() {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => store.delete(key)),
|
||||
clear: vi.fn(() => store.clear()),
|
||||
get length() {
|
||||
return store.size
|
||||
},
|
||||
key: vi.fn((_index: number) => null),
|
||||
_store: store,
|
||||
}
|
||||
}
|
||||
|
||||
describe("consumePendingConnectUrl", () => {
|
||||
let mockStorage: ReturnType<typeof createMockSessionStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
mockStorage = createMockSessionStorage()
|
||||
// @ts-expect-error -- assigning mock sessionStorage in Node
|
||||
globalThis.sessionStorage = mockStorage
|
||||
})
|
||||
|
||||
it("returns null when no pending URL is stored", () => {
|
||||
expect(consumePendingConnectUrl()).toBeNull()
|
||||
})
|
||||
|
||||
it("returns the relative path + query when a full URL is stored", () => {
|
||||
mockStorage._store.set(
|
||||
PENDING_CONNECT_URL_KEY,
|
||||
"https://app.supermemory.ai/auth/connect?callback=http%3A%2F%2Flocalhost%3A3000%2Fcallback&client=opencode",
|
||||
)
|
||||
|
||||
const result = consumePendingConnectUrl()
|
||||
expect(result).toBe(
|
||||
"/auth/connect?callback=http%3A%2F%2Flocalhost%3A3000%2Fcallback&client=opencode",
|
||||
)
|
||||
})
|
||||
|
||||
it("includes the hash fragment if present", () => {
|
||||
mockStorage._store.set(
|
||||
PENDING_CONNECT_URL_KEY,
|
||||
"https://app.supermemory.ai/auth/connect?client=cursor#section",
|
||||
)
|
||||
|
||||
const result = consumePendingConnectUrl()
|
||||
expect(result).toBe("/auth/connect?client=cursor#section")
|
||||
})
|
||||
|
||||
it("removes the stored key after consumption", () => {
|
||||
mockStorage._store.set(
|
||||
PENDING_CONNECT_URL_KEY,
|
||||
"https://app.supermemory.ai/auth/connect?callback=http%3A%2F%2Flocalhost%3A3000%2Fcallback",
|
||||
)
|
||||
|
||||
consumePendingConnectUrl()
|
||||
expect(mockStorage.removeItem).toHaveBeenCalledWith(PENDING_CONNECT_URL_KEY)
|
||||
expect(mockStorage._store.has(PENDING_CONNECT_URL_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns null and logs a warning when sessionStorage throws", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
// @ts-expect-error -- assigning broken sessionStorage
|
||||
globalThis.sessionStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("SecurityError")
|
||||
},
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
|
||||
const result = consumePendingConnectUrl()
|
||||
expect(result).toBeNull()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"Failed to access sessionStorage for pending connect URL",
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("returns null when the stored value is not a valid URL", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
mockStorage._store.set(PENDING_CONNECT_URL_KEY, "not-a-url")
|
||||
|
||||
const result = consumePendingConnectUrl()
|
||||
// `new URL("not-a-url")` throws, so it should be caught
|
||||
expect(result).toBeNull()
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("returns only pathname when URL has no query or hash", () => {
|
||||
mockStorage._store.set(
|
||||
PENDING_CONNECT_URL_KEY,
|
||||
"https://app.supermemory.ai/auth/connect",
|
||||
)
|
||||
|
||||
const result = consumePendingConnectUrl()
|
||||
expect(result).toBe("/auth/connect")
|
||||
})
|
||||
})
|
||||
28
apps/web/lib/constants.ts
Normal file
28
apps/web/lib/constants.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* sessionStorage key used to stash the full plugin-connect URL so that
|
||||
* the onboarding flow can redirect back to it after the user creates
|
||||
* their first organization.
|
||||
*/
|
||||
export const PENDING_CONNECT_URL_KEY = "supermemory-pending-connect-url"
|
||||
|
||||
/**
|
||||
* Consume the pending plugin-connect URL from sessionStorage (if any)
|
||||
* and return the relative path to redirect to. Returns `null` when
|
||||
* there is nothing stored or the stored value is invalid.
|
||||
*
|
||||
* This is extracted into a shared helper so that every onboarding
|
||||
* completion / skip path can reuse it (SetupHeader, IntegrationsStep,
|
||||
* InitialHeader, etc.).
|
||||
*/
|
||||
export function consumePendingConnectUrl(): string | null {
|
||||
try {
|
||||
const pendingUrl = sessionStorage.getItem(PENDING_CONNECT_URL_KEY)
|
||||
if (!pendingUrl) return null
|
||||
sessionStorage.removeItem(PENDING_CONNECT_URL_KEY)
|
||||
const parsed = new URL(pendingUrl)
|
||||
return parsed.pathname + parsed.search + parsed.hash
|
||||
} catch (e) {
|
||||
console.warn("Failed to access sessionStorage for pending connect URL", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue