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 (skip or finish), 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'.
- Wait for full auth state before showing the connect card.
- Extract PENDING_CONNECT_URL_KEY and consumePendingConnectUrl() to a
  shared constants file with 7 unit tests.
This commit is contained in:
Vorflux AI 2026-05-01 19:22:22 +00:00
parent 54bc618010
commit 88f9fb20ef
5 changed files with 187 additions and 8 deletions

View file

@ -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"
@ -374,6 +375,12 @@ export default function OnboardingPage() {
const skippingRef = useRef(false)
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(
@ -467,7 +474,8 @@ export default function OnboardingPage() {
skippingRef.current = true
try {
await ensureOrg()
router.push("/")
const pendingPath = consumePendingConnectUrl()
router.push(pendingPath ?? "/")
} catch (err) {
console.error(err)
skippingRef.current = false
@ -1077,7 +1085,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

View file

@ -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" />

View file

@ -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 (

View 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
View 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
}
}