mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-11 03:37:56 +00:00
fix(web): scope onboarding drafts by account
This commit is contained in:
parent
e651045ac5
commit
0184f20c4c
6 changed files with 567 additions and 28 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
319
apps/web/app/(app)/onboarding/page.test.tsx
Normal file
319
apps/web/app/(app)/onboarding/page.test.tsx
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
95
apps/web/lib/brain-onboarding-draft.test.ts
Normal file
95
apps/web/lib/brain-onboarding-draft.test.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
65
apps/web/lib/brain-onboarding-draft.ts
Normal file
65
apps/web/lib/brain-onboarding-draft.ts
Normal 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 {}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue