fix(web): scope onboarding drafts by account

This commit is contained in:
abhinav7x94 2026-08-16 07:01:32 +05:30
parent e651045ac5
commit 0184f20c4c
6 changed files with 567 additions and 28 deletions

View file

@ -29,5 +29,9 @@ jobs:
- name: Run TypeScript type checking
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
- name: Run web unit tests
working-directory: apps/web
run: bun test
- name: Run Biome CI (format & lint on changed files)
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched

View file

@ -0,0 +1,319 @@
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
mock,
} from "bun:test"
import type { ReactNode } from "react"
import { Window } from "happy-dom"
import { onboardingDraftKey } from "@/lib/brain-onboarding-draft"
type AboutValues = {
name: string
about: string
workspaceName: string
workspaceDomain: string
}
type AboutProps = {
values: AboutValues
onChange: (values: AboutValues) => void
onContinue: () => void | Promise<void>
}
type CreateOrganizationInput = {
name: string
slug: string
metadata: Record<string, unknown> & { brainAbout?: string }
}
const browserWindow = new Window({
url: "https://app.supermemory.ai/onboarding",
})
const browserGlobals = [
"window",
"document",
"navigator",
"Node",
"HTMLElement",
"Event",
"MutationObserver",
"localStorage",
] as const
const originalDescriptors = new Map(
browserGlobals.map((name) => [
name,
Object.getOwnPropertyDescriptor(globalThis, name),
]),
)
const originalActEnvironment = Object.getOwnPropertyDescriptor(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
)
for (const name of browserGlobals) {
Object.defineProperty(globalThis, name, {
configurable: true,
value: browserWindow[name],
})
}
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
})
const React = await import("react")
const accountA = {
id: "user-a",
name: "Alice A",
email: "alice@gmail.com",
image: null,
}
const accountB = {
id: "user-b",
name: "Bob B",
email: "bob@gmail.com",
image: null,
}
let currentUser = accountA
let latestAboutProps: AboutProps | null = null
const router = { push: mock(), replace: mock() }
const searchParams = new URLSearchParams()
const setActiveOrg = mock(async () => {})
const refetchOrganizations = mock(async () => {})
const createOrganization = mock(async (_input: CreateOrganizationInput) => ({
data: { slug: "created-workspace" },
error: null,
}))
const updateUser = mock(async () => ({ data: {}, error: null }))
mock.module("next/navigation", () => ({
useRouter: () => router,
useSearchParams: () => searchParams,
}))
mock.module("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: mock() }),
}))
mock.module("sonner", () => ({
toast: { error: mock(), success: mock() },
}))
mock.module("@lib/auth-context", () => ({
useAuth: () => ({
user: currentUser,
session: { id: `session-${currentUser.id}` },
isSessionPending: false,
isRestoring: false,
org: null,
organizations: [],
setActiveOrg,
refetchOrganizations,
}),
}))
mock.module("@lib/auth", () => ({
authClient: {
organization: {
create: createOrganization,
inviteMember: mock(async () => ({ data: {}, error: null })),
},
updateUser,
},
}))
mock.module("@lib/constants", () => ({
SHARED_TEAM_BRAIN_TAG: "sm_org_shared",
}))
mock.module("@/lib/analytics", () => ({
analytics: new Proxy({} as Record<string, () => void>, {
get: () => () => {},
}),
}))
mock.module("@/lib/company-brain-entry", () => ({
resolveCompanyBrainEntry: () => ({ action: "create" }),
}))
mock.module("@/components/onboarding-brain/shell", () => ({
BrainShell: ({ children }: { children: ReactNode }) =>
React.createElement("main", null, children),
}))
mock.module("@/components/onboarding-brain/step-about", () => ({
StepAbout: (props: AboutProps) => {
latestAboutProps = props
return React.createElement("output", null, JSON.stringify(props.values))
},
}))
mock.module("@/components/onboarding-brain/step-sources", () => ({
StepSources: () => null,
}))
mock.module("@/components/onboarding-brain/step-ingest", () => ({
StepIngest: () => null,
}))
mock.module("@/components/onboarding-brain/step-team", () => ({
StepTeam: () => null,
}))
mock.module("@/components/onboarding-brain/company-brain-onboarding", () => ({
CompanyBrainOnboarding: () => null,
}))
const [{ act }, { createRoot }, { default: BrainOnboardingPage }] =
await Promise.all([
import("react"),
import("react-dom/client"),
import("./page"),
])
let root: ReturnType<typeof createRoot> | null = null
let container: HTMLElement | null = null
function aboutProps(): AboutProps {
if (!latestAboutProps) throw new Error("StepAbout was not rendered")
return latestAboutProps
}
async function renderPage() {
await act(async () => {
root?.render(React.createElement(BrainOnboardingPage))
await Promise.resolve()
})
}
beforeEach(() => {
currentUser = accountA
latestAboutProps = null
localStorage.clear()
for (const key of [...searchParams.keys()]) searchParams.delete(key)
createOrganization.mockClear()
updateUser.mockClear()
router.push.mockClear()
router.replace.mockClear()
container = document.createElement("div")
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root?.unmount())
container?.remove()
root = null
container = null
})
afterAll(() => {
for (const name of browserGlobals) {
const descriptor = originalDescriptors.get(name)
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
else Reflect.deleteProperty(globalThis, name)
}
if (originalActEnvironment) {
Object.defineProperty(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
originalActEnvironment,
)
} else {
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT")
}
void browserWindow.close()
})
describe("onboarding draft ownership", () => {
it("isolates drafts and mutations across an A to B to A account switch", async () => {
localStorage.setItem(
"supermemory-brain-onboarding-v1",
JSON.stringify({ about: { name: "Unowned legacy draft" } }),
)
await renderPage()
const aliceDraft: AboutValues = {
name: "Alice Secret",
about: "Confidential acquisition planning",
workspaceName: "Alice Private Brain",
workspaceDomain: "alice.example",
}
await act(async () => {
aboutProps().onChange(aliceDraft)
await Promise.resolve()
})
expect(aboutProps().values).toEqual(aliceDraft)
currentUser = accountB
await renderPage()
const bobValues = aboutProps().values
expect(bobValues.name).toBe("Bob B")
expect(bobValues.about).toBe("")
expect(bobValues.workspaceName).not.toBe(aliceDraft.workspaceName)
expect(JSON.stringify(bobValues)).not.toContain("Alice")
await act(async () => {
await aboutProps().onContinue()
})
const createPayload = createOrganization.mock.calls[0]?.[0]
expect(createPayload?.name).toBe(bobValues.workspaceName)
expect(createPayload?.metadata.brainAbout).toBeUndefined()
expect(JSON.stringify(createPayload)).not.toContain("Alice")
expect(updateUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "Bob B",
displayUsername: "Bob B",
}),
)
currentUser = accountA
await renderPage()
expect(aboutProps().values).toEqual(aliceDraft)
expect(localStorage.getItem("supermemory-brain-onboarding-v1")).toBeNull()
})
it("starts a fresh user-owned draft when new workspace mode is forced", async () => {
await renderPage()
const existingDraft: AboutValues = {
name: "Alice Secret",
about: "Existing private draft",
workspaceName: "Existing Workspace",
workspaceDomain: "existing.example",
}
await act(async () => {
aboutProps().onChange(existingDraft)
await Promise.resolve()
})
searchParams.set("new", "1")
searchParams.set("name", "Fresh Workspace")
await renderPage()
expect(aboutProps().values).toEqual({
name: "Alice A",
about: "",
workspaceName: "Fresh Workspace",
workspaceDomain: "",
})
const persisted = localStorage.getItem(onboardingDraftKey(accountA.id))
expect(persisted).not.toBeNull()
expect(persisted).not.toContain("Existing private draft")
searchParams.delete("new")
searchParams.delete("name")
await renderPage()
expect(aboutProps().values.about).toBe("")
expect(aboutProps().values.workspaceName).not.toBe(
existingDraft.workspaceName,
)
})
})

View file

@ -9,6 +9,13 @@ import { authClient } from "@lib/auth"
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { analytics } from "@/lib/analytics"
import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry"
import {
clearOnboardingDraft,
discardLegacyOnboardingDraft,
getOnboardingDraftStorage,
readOnboardingDraft,
writeOnboardingDraft,
} from "@/lib/brain-onboarding-draft"
import { BrainShell } from "@/components/onboarding-brain/shell"
import {
StepAbout,
@ -39,10 +46,16 @@ import {
type CompanyBrainConfirmResult,
} from "@/components/onboarding-brain/types"
const STORAGE_KEY = "supermemory-brain-onboarding-v1"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type BrainOnboardingDraft = {
mode?: BrainMode
about?: AboutValues
sources?: SourcesValues
team?: TeamValues
}
const countsAsConnectedSource = (state: unknown) =>
state === "connected" || state === "waitlist"
@ -71,6 +84,12 @@ const getWorkspaceCreationErrorCopy = (message: string) => {
}
export default function BrainOnboardingPage() {
const { user } = useAuth()
if (!user?.id) return null
return <BrainOnboardingContent key={user.id} />
}
function BrainOnboardingContent() {
const router = useRouter()
const params = useSearchParams()
const queryClient = useQueryClient()
@ -122,33 +141,67 @@ export default function BrainOnboardingPage() {
visibility: "team-private",
suggestChanges: false,
})
const [hydratedDraftUserId, setHydratedDraftUserId] = useState<string | null>(
null,
)
useEffect(() => {
if (forceCreate) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const cached = JSON.parse(raw) as {
mode?: BrainMode
about?: AboutValues
sources?: SourcesValues
team?: TeamValues
}
if (cached.mode && !modeParam) setMode(cached.mode)
if (cached.about) setAbout((a) => ({ ...a, ...cached.about }))
if (cached.sources) setSources((s) => ({ ...s, ...cached.sources }))
if (cached.team) setTeam((t) => ({ ...t, ...cached.team }))
} catch {}
}, [forceCreate, modeParam])
const userId = user?.id
if (!userId) {
setHydratedDraftUserId(null)
return
}
const storage = getOnboardingDraftStorage()
if (storage) discardLegacyOnboardingDraft(storage)
const cached =
forceCreate || !storage
? null
: readOnboardingDraft<BrainOnboardingDraft>(storage, userId)
setMode(cached?.mode && !modeParam ? cached.mode : detectedMode)
setAbout({
name: user.name ?? "",
about: "",
workspaceName: nameParam || suggestedWorkspaceName,
workspaceDomain: domain ?? "",
...cached?.about,
})
setSources({
connected: {},
driveScope: "selective",
...cached?.sources,
})
setTeam({
invites: [],
visibility: "team-private",
suggestChanges: false,
...cached?.team,
})
setHydratedDraftUserId(userId)
}, [
user?.id,
user?.name,
forceCreate,
modeParam,
detectedMode,
nameParam,
suggestedWorkspaceName,
domain,
])
useEffect(() => {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ mode, about, sources, team }),
)
} catch {}
}, [mode, about, sources, team])
const userId = user?.id
if (!userId || hydratedDraftUserId !== userId) return
const storage = getOnboardingDraftStorage()
if (!storage) return
writeOnboardingDraft(storage, userId, {
mode,
about,
sources,
team,
})
}, [user?.id, hydratedDraftUserId, mode, about, sources, team])
const navTrigger = useRef<"user" | "auto">("auto")
const startedRef = useRef(false)
@ -226,16 +279,18 @@ export default function BrainOnboardingPage() {
).length,
invites_sent: team.invites.filter((i) => i.email.trim()).length,
})
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
const storage = getOnboardingDraftStorage()
if (storage) {
if (user?.id) clearOnboardingDraft(storage, user.id)
discardLegacyOnboardingDraft(storage)
}
// Extra org from settings: hard-reload so org-scoped caches don't show the previous org's data.
if (forcedCreateRef.current) {
window.location.href = "/?onboarded=1"
return
}
router.push("/?onboarded=1")
}, [router, mode, sources, team])
}, [router, mode, sources, team, user?.id])
const goNext = useCallback(() => {
const idx = steps.indexOf(step)

View file

@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test"
import {
clearOnboardingDraft,
discardLegacyOnboardingDraft,
getOnboardingDraftStorage,
onboardingDraftKey,
readOnboardingDraft,
writeOnboardingDraft,
} from "./brain-onboarding-draft"
function createStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
values,
}
}
describe("brain onboarding draft storage", () => {
test("isolates drafts belonging to different users", () => {
const storage = createStorage()
const accountADraft = {
about: { workspaceName: "Account A workspace" },
team: { invites: [{ email: "private-a@example.com" }] },
}
writeOnboardingDraft(storage, "account-a", accountADraft)
expect(readOnboardingDraft(storage, "account-a")).toEqual(accountADraft)
expect(readOnboardingDraft(storage, "account-b")).toBeNull()
expect(onboardingDraftKey("account-a")).not.toBe(
onboardingDraftKey("account-b"),
)
expect(onboardingDraftKey("account/a")).toBe(
"supermemory-brain-onboarding-v2:account%2Fa",
)
})
test("clears only the current user's draft", () => {
const storage = createStorage()
writeOnboardingDraft(storage, "account-a", { mode: "team" })
writeOnboardingDraft(storage, "account-b", { mode: "personal" })
clearOnboardingDraft(storage, "account-a")
expect(readOnboardingDraft(storage, "account-a")).toBeNull()
expect(readOnboardingDraft(storage, "account-b")).toEqual({
mode: "personal",
})
})
test("discards unattributable legacy drafts without migrating them", () => {
const storage = createStorage()
storage.setItem(
"supermemory-brain-onboarding-v1",
JSON.stringify({ about: { name: "Previous account" } }),
)
discardLegacyOnboardingDraft(storage)
expect(storage.getItem("supermemory-brain-onboarding-v1")).toBeNull()
expect(readOnboardingDraft(storage, "current-account")).toBeNull()
})
test("ignores corrupt and non-object drafts", () => {
const storage = createStorage()
storage.setItem(onboardingDraftKey("account-a"), "not-json")
storage.setItem(onboardingDraftKey("account-b"), "[]")
expect(readOnboardingDraft(storage, "account-a")).toBeNull()
expect(readOnboardingDraft(storage, "account-b")).toBeNull()
})
test("treats a blocked browser storage getter as unavailable", () => {
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage")
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get: () => {
throw new Error("blocked")
},
})
try {
expect(getOnboardingDraftStorage()).toBeNull()
} finally {
if (original) {
Object.defineProperty(globalThis, "localStorage", original)
} else {
Reflect.deleteProperty(globalThis, "localStorage")
}
}
})
})

View file

@ -0,0 +1,65 @@
const LEGACY_ONBOARDING_DRAFT_KEY = "supermemory-brain-onboarding-v1"
const ONBOARDING_DRAFT_KEY_PREFIX = "supermemory-brain-onboarding-v2"
export type OnboardingDraftStorage = Pick<
Storage,
"getItem" | "setItem" | "removeItem"
>
export function getOnboardingDraftStorage(): OnboardingDraftStorage | null {
try {
return globalThis.localStorage ?? null
} catch {
return null
}
}
export function onboardingDraftKey(userId: string): string {
return `${ONBOARDING_DRAFT_KEY_PREFIX}:${encodeURIComponent(userId)}`
}
export function readOnboardingDraft<T extends object>(
storage: OnboardingDraftStorage,
userId: string,
): T | null {
try {
const raw = storage.getItem(onboardingDraftKey(userId))
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null
}
return parsed as T
} catch {
return null
}
}
export function writeOnboardingDraft(
storage: OnboardingDraftStorage,
userId: string,
draft: object,
): void {
try {
storage.setItem(onboardingDraftKey(userId), JSON.stringify(draft))
} catch {}
}
export function clearOnboardingDraft(
storage: OnboardingDraftStorage,
userId: string,
): void {
try {
storage.removeItem(onboardingDraftKey(userId))
} catch {}
}
// Legacy drafts cannot be attributed to an account safely. Discard them
// instead of risking that the next signed-in user sees another user's data.
export function discardLegacyOnboardingDraft(
storage: OnboardingDraftStorage,
): void {
try {
storage.removeItem(LEGACY_ONBOARDING_DRAFT_KEY)
} catch {}
}

View file

@ -126,6 +126,7 @@
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"happy-dom": "^20.9.0",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"wrangler": "^4.26.0"