mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(web): preserve OAuth consent across org selection
This commit is contained in:
parent
5d2b5855fe
commit
aeee1fd839
10 changed files with 757 additions and 37 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
122
apps/web/components/query-client-config.test.ts
Normal file
122
apps/web/components/query-client-config.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
50
apps/web/components/query-client-config.ts
Normal file
50
apps/web/components/query-client-config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
275
apps/web/components/query-client.test.tsx
Normal file
275
apps/web/components/query-client.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
232
apps/web/lib/auth-context.test.tsx
Normal file
232
apps/web/lib/auth-context.test.tsx
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue