fix(web): preserve OAuth consent across org selection

This commit is contained in:
shamAnimates 2026-08-17 14:53:16 +05:30
parent 5d2b5855fe
commit aeee1fd839
10 changed files with 757 additions and 37 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

@ -88,28 +88,28 @@ export default function RootLayout({
disableTransitionOnChange
forcedTheme="dark"
>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ??
"https://api.supermemory.ai"
}
includeCredentials={true}
headers={{ "X-App-Source": "nova" }}
>
<PromoCodeCapture />
<QueryProvider>
<AuthProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<AuthProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<QueryProvider>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ??
"https://api.supermemory.ai"
}
includeCredentials={true}
headers={{ "X-App-Source": "nova" }}
>
<PromoCodeCapture />
<NuqsAdapter>
<Suspense>{children}</Suspense>
<Toaster />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>
</AutumnProvider>
</QueryProvider>
</ErrorTrackingProvider>
</PostHogProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from "bun:test"
import {
createQueryCacheScope,
createQueryClient,
createRouteAwareQueryCacheScope,
shouldScopeQueriesByOrganization,
type QueryCacheScope,
} from "./query-client-config"
const baseScope: QueryCacheScope = {
isSessionPending: false,
isRestoring: false,
sessionId: "session-a",
userId: "user-a",
activeOrganizationId: "organization-a",
organizationId: "organization-a",
}
describe("createQueryCacheScope", () => {
it("is stable while the authentication scope is unchanged", () => {
expect(createQueryCacheScope(baseScope)).toBe(
createQueryCacheScope({ ...baseScope }),
)
})
it.each([
["session", { sessionId: "session-b" }],
["user", { userId: "user-b" }],
["server organization", { activeOrganizationId: "organization-b" }],
["resolved organization", { organizationId: "organization-b" }],
["pending session", { isSessionPending: true }],
["restoring organization", { isRestoring: true }],
[
"logout with a stale organization",
{ sessionId: null, userId: null, activeOrganizationId: null },
],
] satisfies Array<
[string, Partial<QueryCacheScope>]
>)("changes when the %s boundary changes", (_name, change) => {
expect(createQueryCacheScope({ ...baseScope, ...change })).not.toBe(
createQueryCacheScope(baseScope),
)
})
it("does not collide when identifiers contain delimiters", () => {
const first = createQueryCacheScope({
...baseScope,
sessionId: "session|user",
userId: "organization",
})
const second = createQueryCacheScope({
...baseScope,
sessionId: "session",
userId: "user|organization",
})
expect(first).not.toBe(second)
})
it("keeps OAuth consent mounted while the selected organization changes", () => {
expect(createRouteAwareQueryCacheScope(baseScope, "/oauth/consent")).toBe(
createRouteAwareQueryCacheScope(
{
...baseScope,
activeOrganizationId: "organization-b",
organizationId: "organization-b",
},
"/oauth/consent",
),
)
expect(
createRouteAwareQueryCacheScope(
{ ...baseScope, sessionId: "session-b", userId: "user-b" },
"/oauth/consent",
),
).not.toBe(createRouteAwareQueryCacheScope(baseScope, "/oauth/consent"))
})
it("scopes organization caches everywhere except OAuth consent", () => {
expect(shouldScopeQueriesByOrganization("/oauth/consent")).toBe(false)
expect(shouldScopeQueriesByOrganization("/oauth/consent/")).toBe(false)
expect(shouldScopeQueriesByOrganization("/brain")).toBe(true)
expect(shouldScopeQueriesByOrganization("/settings")).toBe(true)
})
})
describe("createQueryClient", () => {
it("does not share tenant data between clients", async () => {
const queryKey = ["documents", "sm_project_default"] as const
const accountAClient = createQueryClient()
const accountBClient = createQueryClient()
let accountBFetches = 0
accountAClient.setQueryData(queryKey, [{ id: "account-a-secret" }])
expect(accountBClient.getQueryData(queryKey)).toBeUndefined()
await expect(
accountBClient.fetchQuery({
queryKey,
queryFn: () => {
accountBFetches += 1
return [{ id: "account-b-document" }]
},
}),
).resolves.toEqual([{ id: "account-b-document" }])
expect(accountBFetches).toBe(1)
accountAClient.clear()
accountBClient.clear()
})
it("preserves the existing query defaults", () => {
const queryClient = createQueryClient()
expect(queryClient.getDefaultOptions().queries).toMatchObject({
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
})
queryClient.clear()
})
})

View file

@ -0,0 +1,50 @@
import { QueryClient } from "@tanstack/react-query"
export interface QueryCacheScope {
isSessionPending: boolean
isRestoring: boolean
sessionId: string | null
userId: string | null
activeOrganizationId: string | null
organizationId: string | null
}
export function shouldScopeQueriesByOrganization(pathname: string): boolean {
return pathname !== "/oauth/consent" && pathname !== "/oauth/consent/"
}
export function createQueryCacheScope(
scope: QueryCacheScope,
options: { includeOrganization?: boolean } = {},
): string {
const includeOrganization = options.includeOrganization ?? true
return JSON.stringify([
scope.isSessionPending,
scope.isRestoring,
scope.sessionId,
scope.userId,
includeOrganization ? scope.activeOrganizationId : null,
includeOrganization ? scope.organizationId : null,
])
}
export function createRouteAwareQueryCacheScope(
scope: QueryCacheScope,
pathname: string,
): string {
return createQueryCacheScope(scope, {
includeOrganization: shouldScopeQueriesByOrganization(pathname),
})
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
},
},
})
}

View file

@ -0,0 +1,275 @@
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"
import { Window } from "happy-dom"
let currentPathname = "/brain"
let currentAuth = {
session: { id: "session-a", activeOrganizationId: "organization-a" },
user: { id: "user-a" },
org: { id: "organization-a" },
isSessionPending: false,
isRestoring: false,
}
mock.module("@lib/auth-context", () => ({
useAuth: () => currentAuth,
}))
mock.module("next/navigation", () => ({
usePathname: () => currentPathname,
}))
const browserWindow = new Window({ url: "https://app.supermemory.ai" })
const installedGlobals = [
"window",
"document",
"navigator",
"Node",
"HTMLElement",
"Event",
"MutationObserver",
] as const
const originalDescriptors = new Map(
installedGlobals.map((name) => [
name,
Object.getOwnPropertyDescriptor(globalThis, name),
]),
)
for (const name of installedGlobals) {
Object.defineProperty(globalThis, name, {
configurable: true,
value: browserWindow[name],
})
}
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
})
const [{ act, useEffect }, { createRoot }, { useQueryClient }] =
await Promise.all([
import("react"),
import("react-dom/client"),
import("@tanstack/react-query"),
])
const { QueryProvider, ScopedQueryProvider } = await import("./query-client")
beforeEach(() => {
currentPathname = "/brain"
currentAuth = {
session: { id: "session-a", activeOrganizationId: "organization-a" },
user: { id: "user-a" },
org: { id: "organization-a" },
isSessionPending: false,
isRestoring: false,
}
})
afterAll(() => {
for (const name of installedGlobals) {
const descriptor = originalDescriptors.get(name)
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
else Reflect.deleteProperty(globalThis, name)
}
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT")
void browserWindow.close()
})
describe("ScopedQueryProvider", () => {
it("discards the mounted cache when the auth scope changes", async () => {
const clients: ReturnType<typeof useQueryClient>[] = []
let mounts = 0
let unmounts = 0
function Probe() {
const queryClient = useQueryClient()
clients.push(queryClient)
useEffect(() => {
mounts += 1
return () => {
unmounts += 1
}
}, [])
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
await act(async () => {
root.render(
<ScopedQueryProvider scope="account-a">
<Probe />
</ScopedQueryProvider>,
)
})
const accountAClient = clients.at(-1)
expect(accountAClient).toBeDefined()
accountAClient?.setQueryData(
["documents", "sm_project_default"],
[{ id: "account-a-secret" }],
)
await act(async () => {
root.render(
<ScopedQueryProvider scope="account-a">
<Probe />
</ScopedQueryProvider>,
)
})
expect(clients.at(-1)).toBe(accountAClient)
expect(mounts).toBe(1)
expect(unmounts).toBe(0)
await act(async () => {
root.render(
<ScopedQueryProvider scope="account-b">
<Probe />
</ScopedQueryProvider>,
)
})
const accountBClient = clients.at(-1)
expect(accountBClient).not.toBe(accountAClient)
expect(accountAClient?.getQueryCache().getAll()).toHaveLength(0)
expect(
accountBClient?.getQueryData(["documents", "sm_project_default"]),
).toBeUndefined()
accountBClient?.setQueryData(
["documents", "sm_project_default"],
[{ id: "account-b-secret" }],
)
expect(mounts).toBe(2)
expect(unmounts).toBe(1)
await act(async () => {
root.render(
<ScopedQueryProvider scope="account-a">
<Probe />
</ScopedQueryProvider>,
)
})
const accountAReturnClient = clients.at(-1)
expect(accountAReturnClient).not.toBe(accountAClient)
expect(accountAReturnClient).not.toBe(accountBClient)
expect(accountBClient?.getQueryCache().getAll()).toHaveLength(0)
expect(
accountAReturnClient?.getQueryData(["documents", "sm_project_default"]),
).toBeUndefined()
expect(mounts).toBe(3)
expect(unmounts).toBe(2)
await act(async () => root.unmount())
container.remove()
})
})
describe("QueryProvider", () => {
it("preserves consent state across org selection but not account changes", async () => {
const clients: ReturnType<typeof useQueryClient>[] = []
let mounts = 0
function Probe() {
clients.push(useQueryClient())
useEffect(() => {
mounts += 1
}, [])
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
currentPathname = "/oauth/consent"
await act(async () => {
root.render(
<QueryProvider>
<Probe />
</QueryProvider>,
)
})
const accountAClient = clients.at(-1)
accountAClient?.setQueryData(["consent-state"], "account-a")
currentAuth = {
...currentAuth,
session: {
...currentAuth.session,
activeOrganizationId: "organization-b",
},
org: { id: "organization-b" },
}
await act(async () => {
root.render(
<QueryProvider>
<Probe />
</QueryProvider>,
)
})
expect(clients.at(-1)).toBe(accountAClient)
expect(clients.at(-1)?.getQueryData<string>(["consent-state"])).toBe(
"account-a",
)
expect(mounts).toBe(1)
currentAuth = {
...currentAuth,
session: { id: "session-b", activeOrganizationId: "organization-b" },
user: { id: "user-b" },
}
await act(async () => {
root.render(
<QueryProvider>
<Probe />
</QueryProvider>,
)
})
expect(clients.at(-1)).not.toBe(accountAClient)
expect(clients.at(-1)?.getQueryData(["consent-state"])).toBeUndefined()
expect(mounts).toBe(2)
await act(async () => root.unmount())
container.remove()
})
it("still rotates organization-scoped caches outside consent", async () => {
const clients: ReturnType<typeof useQueryClient>[] = []
function Probe() {
clients.push(useQueryClient())
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
await act(async () => {
root.render(
<QueryProvider>
<Probe />
</QueryProvider>,
)
})
const organizationAClient = clients.at(-1)
currentAuth = {
...currentAuth,
session: {
...currentAuth.session,
activeOrganizationId: "organization-b",
},
org: { id: "organization-b" },
}
await act(async () => {
root.render(
<QueryProvider>
<Probe />
</QueryProvider>,
)
})
expect(clients.at(-1)).not.toBe(organizationAClient)
await act(async () => root.unmount())
container.remove()
})
})

View file

@ -1,23 +1,47 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useState } from "react"
import { useAuth } from "@lib/auth-context"
import { QueryClientProvider } from "@tanstack/react-query"
import { usePathname } from "next/navigation"
import { type ReactNode, useEffect, useState } from "react"
import {
createQueryClient,
createRouteAwareQueryCacheScope,
} from "./query-client-config"
export const QueryProvider = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
},
},
}),
)
function QueryClientOwner({ children }: { children: ReactNode }) {
const [queryClient] = useState(createQueryClient)
useEffect(() => () => queryClient.clear(), [queryClient])
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
export function ScopedQueryProvider({
children,
scope,
}: {
children: ReactNode
scope: string
}) {
return <QueryClientOwner key={scope}>{children}</QueryClientOwner>
}
export function QueryProvider({ children }: { children: ReactNode }) {
const { session, user, org, isSessionPending, isRestoring } = useAuth()
const pathname = usePathname()
const scope = createRouteAwareQueryCacheScope(
{
isSessionPending,
isRestoring,
sessionId: session?.id ?? null,
userId: user?.id ?? null,
activeOrganizationId: session?.activeOrganizationId ?? null,
organizationId: org?.id ?? null,
},
pathname,
)
return <ScopedQueryProvider scope={scope}>{children}</ScopedQueryProvider>
}

View file

@ -0,0 +1,232 @@
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
mock,
} from "bun:test"
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"
const ISOLATED_RUN_ENV = "AUTH_CONTEXT_MOUNTED_TEST"
const testFilePath = fileURLToPath(import.meta.url)
if (process.env[ISOLATED_RUN_ENV] === "1") {
await registerMountedAuthProviderTests()
} else {
describe("AuthProvider mounted regressions", () => {
it("passes in an isolated Bun process", () => {
const result = spawnSync(process.execPath, ["test", testFilePath], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, [ISOLATED_RUN_ENV]: "1" },
})
if (result.status !== 0) {
throw new Error(
`Isolated AuthProvider tests failed:\n${result.stdout}\n${result.stderr}`,
)
}
expect(result.status).toBe(0)
})
})
}
async function registerMountedAuthProviderTests() {
const { Window } = await import("happy-dom")
const currentOrganization = { id: "org-current", slug: "current" }
const savedOrganization = { id: "org-saved", slug: "saved" }
const organizations = [currentOrganization, savedOrganization]
const sessionResult = {
data: {
session: {
id: "session-a",
activeOrganizationId: currentOrganization.id,
},
user: { id: "user-a" },
},
isPending: false,
}
const refetchOrganizations = mock(async () => ({ data: organizations }))
const setActive = mock(
async (input: {
organizationSlug?: string
organizationId?: string | null
}) => ({
data:
input.organizationSlug === savedOrganization.slug
? savedOrganization
: currentOrganization,
}),
)
const getFullOrganization = mock(async () => ({
data: currentOrganization,
}))
const authModuleFactory = () => ({
authClient: {
$Infer: {},
useListOrganizations: () => ({
data: organizations,
isPending: false,
refetch: refetchOrganizations,
}),
organization: {
getFullOrganization,
setActive,
},
},
useSession: () => sessionResult,
})
const authModulePath = fileURLToPath(
new URL("../../../packages/lib/auth.ts", import.meta.url),
)
mock.module(authModulePath, authModuleFactory)
mock.module("@lib/auth", authModuleFactory)
const browserWindow = new Window({ url: "https://app.supermemory.ai/brain" })
const installedGlobals = [
"window",
"document",
"navigator",
"Node",
"HTMLElement",
"Event",
"MutationObserver",
"localStorage",
] as const
const originalDescriptors = new Map(
installedGlobals.map((name) => [
name,
Object.getOwnPropertyDescriptor(globalThis, name),
]),
)
for (const name of installedGlobals) {
Object.defineProperty(globalThis, name, {
configurable: true,
value: browserWindow[name],
})
}
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
writable: true,
value: true,
})
const { act, useSyncExternalStore } = await import("react")
let currentPathname = window.location.pathname
const pathnameListeners = new Set<() => void>()
const getPathnameSnapshot = () => currentPathname
const subscribeToPathname = (listener: () => void) => {
pathnameListeners.add(listener)
return () => pathnameListeners.delete(listener)
}
mock.module("next/navigation", () => ({
usePathname: () =>
useSyncExternalStore(
subscribeToPathname,
getPathnameSnapshot,
getPathnameSnapshot,
),
}))
const [{ createRoot }, { AuthProvider }] = await Promise.all([
import("react-dom/client"),
import("@lib/auth-context"),
])
const savedOrganizationKey = "supermemory-consumer-last-org-slug"
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
const setPathname = (pathname: string) => {
currentPathname = pathname
window.history.replaceState(null, "", pathname)
for (const listener of pathnameListeners) listener()
}
beforeEach(() => {
setActive.mockClear()
getFullOrganization.mockClear()
refetchOrganizations.mockClear()
window.localStorage.clear()
window.localStorage.setItem(savedOrganizationKey, savedOrganization.slug)
setPathname("/brain")
container = document.createElement("div")
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
})
afterAll(() => {
for (const name of installedGlobals) {
const descriptor = originalDescriptors.get(name)
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
else Reflect.deleteProperty(globalThis, name)
}
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT")
void browserWindow.close()
})
describe("saved organization restoration", () => {
it("restores the saved organization away from consent", async () => {
setPathname("/brain")
await act(async () => {
root.render(
<AuthProvider>
<div />
</AuthProvider>,
)
})
expect(setActive).toHaveBeenCalledTimes(1)
expect(setActive).toHaveBeenCalledWith({
organizationSlug: savedOrganization.slug,
})
expect(getFullOrganization).not.toHaveBeenCalled()
})
for (const pathname of ["/oauth/consent", "/oauth/consent/"]) {
it(`leaves ${pathname} organization selection to consent`, async () => {
setPathname(pathname)
await act(async () => {
root.render(
<AuthProvider>
<div />
</AuthProvider>,
)
})
expect(getFullOrganization).toHaveBeenCalledTimes(1)
expect(setActive).not.toHaveBeenCalled()
})
}
it("restores the saved organization after consent navigates to brain", async () => {
setPathname("/oauth/consent/")
await act(async () => {
root.render(
<AuthProvider>
<div />
</AuthProvider>,
)
})
expect(getFullOrganization).toHaveBeenCalledTimes(1)
expect(setActive).not.toHaveBeenCalled()
await act(async () => {
setPathname("/brain")
})
expect(setActive).toHaveBeenCalledTimes(1)
expect(setActive).toHaveBeenCalledWith({
organizationSlug: savedOrganization.slug,
})
})
})
}

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"

View file

@ -251,6 +251,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",

View file

@ -1,5 +1,6 @@
"use client"
import { usePathname } from "next/navigation"
import {
createContext,
type ReactNode,
@ -18,6 +19,10 @@ type OrganizationListItem = NonNullable<
const STORAGE_KEY = "supermemory-consumer-last-org-slug"
function isOAuthConsentPath(pathname: string): boolean {
return pathname === "/oauth/consent" || pathname === "/oauth/consent/"
}
// Reads ?org=<slug> from the URL once and removes it, so a deep link that
// selects an org doesn't re-fire on refresh or back-navigation.
function consumeRequestedOrgSlug(): string | null {
@ -53,6 +58,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const { data: session, isPending: isSessionPending } = useSession()
const currentPathname = usePathname()
const [org, setOrg] = useState<Organization | null>(null)
const [isRestoring, setIsRestoring] = useState(true)
const {
@ -129,9 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const run = async () => {
try {
// OAuth consent owns org selection for the authorization transaction.
const shouldRestoreSavedOrg =
typeof window === "undefined" ||
window.location.pathname !== "/oauth/consent"
const shouldRestoreSavedOrg = !isOAuthConsentPath(currentPathname)
if (orgs.length === 0) {
if (!cancelled) setOrg(null)
@ -207,7 +211,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true
}
}, [isSessionPending, session, orgsData, orgsPending, setActiveOrg])
}, [
currentPathname,
isSessionPending,
session,
orgsData,
orgsPending,
setActiveOrg,
])
useEffect(() => {
if (typeof window === "undefined") return