This commit is contained in:
abhinav7x94 2026-08-26 04:01:27 +05:30 committed by GitHub
commit fd85449dbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 829 additions and 68 deletions

View file

@ -27,7 +27,10 @@ jobs:
run: bun install --frozen-lockfile
- name: Run TypeScript type checking
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
run: bunx turbo run check-types --filter='@repo/lib' --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
- name: Run shared library tests
run: bun test packages/lib
- name: Run Biome CI (format & lint on changed files)
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched

View file

@ -1,8 +1,17 @@
"use client"
import { authClient, useSession } from "@lib/auth"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { createAuthSessionScope } from "@lib/scoped-auth-state"
import { useSearchParams } from "next/navigation"
import { Suspense, useCallback, useMemo, useState } from "react"
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import {
CardShell,
ConsentCard,
@ -16,20 +25,28 @@ const API_URL =
function OAuthConsentContent() {
const params = useSearchParams()
const { data: session } = useSession()
const { data: organizations } = authClient.useListOrganizations()
const { organizations, user } = useAuth()
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null)
const [done, setDone] = useState<"approved" | "denied" | null>(null)
const [error, setError] = useState<string | null>(null)
const [availableTags, setAvailableTags] = useState<string[]>([])
const [tagsLoading, setTagsLoading] = useState(false)
const [tagsLoaded, setTagsLoaded] = useState(false)
const tagsRequestRef = useRef<AbortController | null>(null)
const submitRequestRef = useRef<AbortController | null>(null)
useEffect(() => {
return () => {
tagsRequestRef.current?.abort()
submitRequestRef.current?.abort()
}
}, [])
const orgs = useMemo(
() => (organizations ?? []).map((o) => ({ id: o.id, name: o.name })),
[organizations],
)
const activeOrgId = session?.session.activeOrganizationId ?? null
const clientId = params.get("client_id") ?? ""
const plugin = clientId ? (OAUTH_PLUGINS[clientId] ?? null) : null
const appLabel = plugin?.name ?? "An application"
@ -43,25 +60,46 @@ function OAuthConsentContent() {
const onEnterOrg = useCallback(
async (orgId: string) => {
setError(null)
if (orgId !== activeOrgId) {
try {
await authClient.organization.setActive({ organizationId: orgId })
} catch (err) {
setError("Couldn't switch to that organization. Try again.")
throw err
}
}
tagsRequestRef.current?.abort()
tagsRequestRef.current = null
setTagsLoading(false)
setTagsLoaded(false)
setAvailableTags([])
try {
const result = await authClient.organization.setActive({
organizationId: orgId,
})
if (result.error || result.data?.id !== orgId) {
throw new Error(
result.error?.message ?? "Organization switch failed",
)
}
} catch (err) {
setError("Couldn't switch to that organization. Try again.")
throw err
}
},
[activeOrgId],
[],
)
const onScopedOpen = useCallback(() => {
if (tagsLoading || availableTags.length > 0) return
if (tagsLoading || tagsLoaded) return
const controller = new AbortController()
tagsRequestRef.current?.abort()
tagsRequestRef.current = controller
setTagsLoading(true)
fetch(`${API_URL}/v3/container-tags/list`, { credentials: "include" })
fetch(`${API_URL}/v3/container-tags/list`, {
credentials: "include",
signal: controller.signal,
})
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (
tagsRequestRef.current !== controller ||
controller.signal.aborted
) {
return
}
const list = (d?.containerTags ?? d?.tags ?? d ?? []) as unknown[]
const names = (Array.isArray(list) ? list : [])
.map((t) =>
@ -75,8 +113,15 @@ function OAuthConsentContent() {
setAvailableTags(Array.from(new Set(names)))
})
.catch(() => {})
.finally(() => setTagsLoading(false))
}, [tagsLoading, availableTags.length])
.finally(() => {
if (tagsRequestRef.current !== controller) return
tagsRequestRef.current = null
if (!controller.signal.aborted) {
setTagsLoading(false)
setTagsLoaded(true)
}
})
}, [tagsLoading, tagsLoaded])
const onSubmit = useCallback(
async (accept: boolean, scope: ConsentScope) => {
@ -89,6 +134,9 @@ function OAuthConsentContent() {
)
return
}
const controller = new AbortController()
submitRequestRef.current?.abort()
submitRequestRef.current = controller
setSubmitting(accept ? "approve" : "deny")
setError(null)
try {
@ -106,7 +154,9 @@ function OAuthConsentContent() {
containerTags: scope.scopeType === "scoped" ? scope.tags : [],
expiresDays: scope.expiresDays,
}),
signal: controller.signal,
})
if (controller.signal.aborted) return
if (!scopeRes.ok) {
const scopeData = (await scopeRes.json().catch(() => ({}))) as {
error?: string
@ -127,7 +177,9 @@ function OAuthConsentContent() {
Accept: "application/json",
},
body: JSON.stringify({ accept, oauth_query: oauthQuery }),
signal: controller.signal,
})
if (controller.signal.aborted) return
const data = (await res.json().catch(() => ({}))) as {
url?: string
redirectURI?: string
@ -152,6 +204,7 @@ function OAuthConsentContent() {
"Authorization failed.",
)
}
if (controller.signal.aborted) return
// Many clients use a loopback or custom-scheme redirect URI that hands
// off without replacing this tab, but the server still has to provide it.
const redirectUrl = data.url ?? data.redirectURI ?? data.redirect_uri
@ -180,9 +233,14 @@ function OAuthConsentContent() {
setDone(accept ? "approved" : "denied")
if (redirectUrl) window.location.href = redirectUrl
} catch (err) {
if (controller.signal.aborted) return
console.error("OAuth consent failed:", err)
setError(err instanceof Error ? err.message : "Authorization failed.")
setSubmitting(null)
} finally {
if (submitRequestRef.current === controller) {
submitRequestRef.current = null
}
}
},
[clientId],
@ -230,13 +288,20 @@ function OAuthConsentContent() {
orgs={orgs}
submitting={submitting}
tagsLoading={tagsLoading}
userEmail={session?.user?.email}
userEmail={user?.email}
verified={!!plugin}
/>
)
}
export default function OAuthConsentPage() {
const { isSessionPending, session, user } = useAuth()
const sessionScope = createAuthSessionScope({
isPending: isSessionPending,
sessionId: session?.id,
userId: user?.id,
})
return (
<Suspense
fallback={
@ -247,7 +312,7 @@ export default function OAuthConsentPage() {
</CardShell>
}
>
<OAuthConsentContent />
<OAuthConsentContent key={sessionScope ?? "no-session"} />
</Suspense>
)
}

View file

@ -309,6 +309,9 @@
"tailwind-merge": "^3.3.1",
"zod": "^3.25.76",
},
"devDependencies": {
"happy-dom": "^20.9.0",
},
},
"packages/memory-graph": {
"name": "@supermemory/memory-graph",

View file

@ -0,0 +1,444 @@
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
mock,
} from "bun:test"
import { Window } from "happy-dom"
type TestOrganization = {
id: string
name: string
slug: string
createdAt: Date
}
type TestSession = {
session: {
id: string
activeOrganizationId: string | null
}
user: {
id: string
}
}
type OrganizationListResult = {
data: TestOrganization[]
error: null
}
const browserWindow = new Window({ url: "https://app.supermemory.ai" })
const installedGlobals = [
"window",
"document",
"navigator",
"Node",
"HTMLElement",
"Event",
"MutationObserver",
"localStorage",
] as const
const originalDescriptors = new Map(
installedGlobals.map((name) => [
name,
Object.getOwnPropertyDescriptor(globalThis, name),
]),
)
const originalActEnvironmentDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
)
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,
})
let currentSession: TestSession | null = null
let isSessionPending = false
let listRequests: Array<{
resolve: (result: OrganizationListResult) => void
reject: (error: Error) => void
}> = []
let legacyOrganizations: TestOrganization[] = []
const organizationById = new Map<string, TestOrganization>()
mock.module("./auth", () => ({
authClient: {
useListOrganizations: () => ({
data: legacyOrganizations,
isPending: false,
refetch: async () => ({ data: legacyOrganizations, error: null }),
}),
organization: {
list: () =>
new Promise<OrganizationListResult>((resolve, reject) => {
listRequests.push({ resolve, reject })
}),
getFullOrganization: async () => ({
data:
organizationById.get(
currentSession?.session.activeOrganizationId ?? "",
) ?? null,
error: null,
}),
setActive: async () => ({ data: null, error: null }),
},
},
useSession: () => ({
data: currentSession,
isPending: isSessionPending,
}),
}))
const [{ act }, { createRoot }, { AuthProvider, useAuth }] = await Promise.all([
import("react"),
import("react-dom/client"),
import("./auth-context"),
])
const mountedRoots: Array<ReturnType<typeof createRoot>> = []
const mountedContainers: HTMLElement[] = []
const accountAOrganization: TestOrganization = {
id: "organization-a",
name: "Private Account A",
slug: "account-a",
createdAt: new Date("2026-01-01T00:00:00Z"),
}
const accountBOrganization: TestOrganization = {
id: "organization-b",
name: "Private Account B",
slug: "account-b",
createdAt: new Date("2026-01-02T00:00:00Z"),
}
function sessionFor(
sessionId: string,
userId: string,
organizationId: string,
): TestSession {
return {
session: { id: sessionId, activeOrganizationId: organizationId },
user: { id: userId },
}
}
beforeEach(() => {
currentSession = null
isSessionPending = false
listRequests = []
legacyOrganizations = []
organizationById.clear()
organizationById.set(accountAOrganization.id, accountAOrganization)
organizationById.set(accountBOrganization.id, accountBOrganization)
localStorage.clear()
})
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) root.unmount()
})
for (const container of mountedContainers.splice(0)) 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)
}
if (originalActEnvironmentDescriptor) {
Object.defineProperty(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
originalActEnvironmentDescriptor,
)
} else {
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT")
}
void browserWindow.close()
})
describe("AuthProvider organization scoping", () => {
it("keeps a cold session in the restoring state until auth resolves", async () => {
const snapshots: Array<{
sessionId: string | null
organizationIds: string[] | null
isRestoring: boolean
}> = []
function Probe() {
const { session, organizations, isRestoring } = useAuth()
snapshots.push({
sessionId: session?.id ?? null,
organizationIds: organizations?.map(({ id }) => id) ?? null,
isRestoring,
})
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
mountedContainers.push(container)
mountedRoots.push(root)
isSessionPending = true
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
expect(snapshots.at(-1)).toEqual({
sessionId: null,
organizationIds: null,
isRestoring: true,
})
expect(listRequests).toHaveLength(0)
currentSession = sessionFor("session-a", "user-a", accountAOrganization.id)
isSessionPending = false
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
expect(snapshots.at(-1)).toEqual({
sessionId: "session-a",
organizationIds: null,
isRestoring: true,
})
expect(listRequests).toHaveLength(1)
})
it("finishes restoring when the organization request rejects", async () => {
const snapshots: Array<{
organizationIds: string[] | null
activeOrganizationId: string | null
isRestoring: boolean
}> = []
function Probe() {
const { organizations, org, isRestoring } = useAuth()
snapshots.push({
organizationIds: organizations?.map(({ id }) => id) ?? null,
activeOrganizationId: org?.id ?? null,
isRestoring,
})
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
mountedContainers.push(container)
mountedRoots.push(root)
currentSession = sessionFor("session-a", "user-a", accountAOrganization.id)
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
expect(listRequests).toHaveLength(1)
await act(async () => {
listRequests[0]?.reject(new Error("offline"))
await Promise.resolve()
})
expect(snapshots.at(-1)).toEqual({
organizationIds: [],
activeOrganizationId: null,
isRestoring: false,
})
})
it("does not let an older same-session list replace a newer refetch", async () => {
const observedOrganizationIds: Array<string[] | null> = []
let refetchOrganizations: (() => Promise<unknown>) | null = null
function Probe() {
const auth = useAuth()
refetchOrganizations = auth.refetchOrganizations
observedOrganizationIds.push(
auth.organizations?.map(({ id }) => id) ?? null,
)
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
mountedContainers.push(container)
mountedRoots.push(root)
currentSession = sessionFor("session-a", "user-a", accountAOrganization.id)
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
expect(listRequests).toHaveLength(1)
await act(async () => {
void refetchOrganizations?.()
await Promise.resolve()
})
expect(listRequests).toHaveLength(2)
await act(async () => {
listRequests[1]?.resolve({
data: [accountAOrganization, accountBOrganization],
error: null,
})
await Promise.resolve()
})
expect(observedOrganizationIds.at(-1)).toEqual([
accountAOrganization.id,
accountBOrganization.id,
])
await act(async () => {
listRequests[0]?.resolve({ data: [accountAOrganization], error: null })
await Promise.resolve()
})
expect(observedOrganizationIds.at(-1)).toEqual([
accountAOrganization.id,
accountBOrganization.id,
])
})
it("hides account A before account B's organizations load", async () => {
const snapshots: Array<{
organizationIds: string[] | null
activeOrganizationId: string | null
isRestoring: boolean
}> = []
function Probe() {
const { organizations, org, isRestoring } = useAuth()
snapshots.push({
organizationIds: organizations?.map(({ id }) => id) ?? null,
activeOrganizationId: org?.id ?? null,
isRestoring,
})
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
mountedContainers.push(container)
mountedRoots.push(root)
legacyOrganizations = [accountAOrganization]
currentSession = sessionFor("session-a", "user-a", accountAOrganization.id)
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
await act(async () => {
listRequests[0]?.resolve({ data: [accountAOrganization], error: null })
await Promise.resolve()
})
expect(snapshots.at(-1)).toEqual({
organizationIds: [accountAOrganization.id],
activeOrganizationId: accountAOrganization.id,
isRestoring: false,
})
currentSession = sessionFor("session-b", "user-b", accountBOrganization.id)
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
expect(snapshots.at(-1)).toEqual({
organizationIds: null,
activeOrganizationId: null,
isRestoring: true,
})
expect(listRequests).toHaveLength(2)
await act(async () => {
listRequests[1]?.resolve({ data: [accountBOrganization], error: null })
await Promise.resolve()
})
expect(snapshots.at(-1)).toEqual({
organizationIds: [accountBOrganization.id],
activeOrganizationId: accountBOrganization.id,
isRestoring: false,
})
})
it("ignores a late organization response from the previous account", async () => {
const observedOrganizationIds: Array<string[] | null> = []
function Probe() {
observedOrganizationIds.push(
useAuth().organizations?.map(({ id }) => id) ?? null,
)
return null
}
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
mountedContainers.push(container)
mountedRoots.push(root)
currentSession = sessionFor("session-a", "user-a", accountAOrganization.id)
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
currentSession = sessionFor("session-b", "user-b", accountBOrganization.id)
await act(async () => {
root.render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
})
expect(listRequests).toHaveLength(2)
await act(async () => {
listRequests[1]?.resolve({ data: [accountBOrganization], error: null })
await Promise.resolve()
})
expect(observedOrganizationIds.at(-1)).toEqual([accountBOrganization.id])
await act(async () => {
listRequests[0]?.resolve({ data: [accountAOrganization], error: null })
await Promise.resolve()
})
expect(observedOrganizationIds.at(-1)).toEqual([accountBOrganization.id])
})
})

View file

@ -6,14 +6,22 @@ import {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { authClient, useSession } from "./auth"
import {
createAuthSessionScope,
isOAuthConsentPath,
readScopedAuthValue,
scopedAuthValueForResponse,
type ScopedAuthValue,
} from "./scoped-auth-state"
type Organization = typeof authClient.$Infer.ActiveOrganization
type SessionData = NonNullable<ReturnType<typeof useSession>["data"]>
type OrganizationListItem = NonNullable<
ReturnType<typeof authClient.useListOrganizations>["data"]
Awaited<ReturnType<typeof authClient.organization.list>>["data"]
>[number]
const STORAGE_KEY = "supermemory-consumer-last-org-slug"
@ -53,88 +61,169 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const { data: session, isPending: isSessionPending } = useSession()
const [org, setOrg] = useState<Organization | null>(null)
const [isRestoring, setIsRestoring] = useState(true)
const {
data: orgsData,
refetch: refetchOrgsQuery,
isPending: orgsPending,
} = authClient.useListOrganizations()
const sessionScope = createAuthSessionScope({
isPending: isSessionPending,
sessionId: session?.session.id,
userId: session?.user.id,
})
const currentSessionScopeRef = useRef(sessionScope)
currentSessionScopeRef.current = sessionScope
const organizationsRequestRef = useRef(0)
const [organizationsState, setOrganizationsState] = useState<ScopedAuthValue<
OrganizationListItem[]
> | null>(null)
const [organizationState, setOrganizationState] =
useState<ScopedAuthValue<Organization | null> | null>(null)
const organizations =
session?.session == null ? null : orgsPending ? null : (orgsData ?? [])
const refetchOrganizations = useCallback(
() => Promise.resolve(refetchOrgsQuery()),
[refetchOrgsQuery],
const organizationsRead = readScopedAuthValue(
sessionScope,
organizationsState,
)
const organizationRead = readScopedAuthValue(sessionScope, organizationState)
const organizations = organizationsRead.ready ? organizationsRead.data : null
const org = organizationRead.ready ? organizationRead.data : null
const isRestoring =
isSessionPending ||
Boolean(
session?.session &&
(!sessionScope || !organizationsRead.ready || !organizationRead.ready),
)
const refetchOrganizations = useCallback(async () => {
const requestedScope = currentSessionScopeRef.current
if (!requestedScope) return null
const requestId = ++organizationsRequestRef.current
const commitOrganizations = (data: OrganizationListItem[]) => {
if (requestId !== organizationsRequestRef.current) return
const nextState = scopedAuthValueForResponse(
currentSessionScopeRef.current,
requestedScope,
data,
)
if (nextState) setOrganizationsState(nextState)
}
try {
const result = await authClient.organization.list()
commitOrganizations(result.data ?? [])
return result
} catch {
commitOrganizations([])
return null
}
}, [])
const setActiveOrg = useCallback(async (slug: string) => {
if (!slug) return
const requestedScope = currentSessionScopeRef.current
if (!requestedScope) return
const res = await authClient.organization.setActive({
organizationSlug: slug,
})
setOrg(res?.data ?? null)
localStorage.setItem(STORAGE_KEY, slug)
if (res.error || !res.data) {
throw new Error(res.error?.message ?? "Organization switch failed")
}
const nextState = scopedAuthValueForResponse(
currentSessionScopeRef.current,
requestedScope,
res.data,
)
if (nextState) {
setOrganizationState(nextState)
localStorage.setItem(STORAGE_KEY, slug)
}
}, [])
const clearActiveOrg = useCallback(async () => {
const requestedScope = currentSessionScopeRef.current
if (!requestedScope) return
try {
await authClient.organization.setActive({ organizationId: null })
} catch {}
setOrg(null)
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
const nextState = scopedAuthValueForResponse(
currentSessionScopeRef.current,
requestedScope,
null,
)
if (nextState) {
setOrganizationState(nextState)
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
}
}, [])
const updateOrgMetadata = useCallback((partial: Record<string, unknown>) => {
setOrg((prev) => {
if (!prev) return prev
const currentScope = currentSessionScopeRef.current
if (!currentScope) return
setOrganizationState((prev) => {
if (prev?.scope !== currentScope || !prev.data) return prev
return {
...prev,
metadata: {
...prev.metadata,
...partial,
scope: currentScope,
data: {
...prev.data,
metadata: {
...prev.data.metadata,
...partial,
},
},
}
})
}, [])
const refetchActiveOrg = useCallback(async () => {
const requestedScope = currentSessionScopeRef.current
if (!requestedScope) return null
const full = await authClient.organization.getFullOrganization()
const nextOrg = full?.data ?? null
setOrg(nextOrg)
return nextOrg
const nextState = scopedAuthValueForResponse(
currentSessionScopeRef.current,
requestedScope,
nextOrg,
)
if (!nextState) return null
setOrganizationState(nextState)
return nextState.data
}, [])
useEffect(() => {
if (isSessionPending) return
if (!session?.session) {
setIsRestoring(false)
setOrg(null)
if (!sessionScope) {
setOrganizationsState(null)
setOrganizationState(null)
return
}
if (orgsPending || orgsData === undefined) {
setIsRestoring(true)
return
}
void refetchOrganizations()
}, [isSessionPending, refetchOrganizations, sessionScope])
const orgs = orgsData ?? []
useEffect(() => {
if (isSessionPending) return
if (!session?.session || !sessionScope || organizations === null) return
const requestedScope = sessionScope
const orgs = organizations
let cancelled = false
const commitOrganization = (nextOrg: Organization | null) => {
if (cancelled) return
const nextState = scopedAuthValueForResponse(
currentSessionScopeRef.current,
requestedScope,
nextOrg,
)
if (nextState) setOrganizationState(nextState)
}
const run = async () => {
try {
// OAuth consent owns org selection for the authorization transaction.
const shouldRestoreSavedOrg =
typeof window === "undefined" ||
window.location.pathname !== "/oauth/consent"
!isOAuthConsentPath(window.location.pathname)
if (orgs.length === 0) {
if (!cancelled) setOrg(null)
commitOrganization(null)
return
}
@ -148,7 +237,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (match) {
if (activeOrgId === match.id) {
const full = await authClient.organization.getFullOrganization()
if (!cancelled) setOrg(full?.data ?? null)
commitOrganization(full?.data ?? null)
} else {
await setActiveOrg(requestedSlug)
}
@ -161,7 +250,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!one) return
if (activeOrgId === one.id) {
const full = await authClient.organization.getFullOrganization()
if (!cancelled) setOrg(full?.data ?? null)
commitOrganization(full?.data ?? null)
} else {
await setActiveOrg(one.slug)
}
@ -175,7 +264,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (match) {
if (activeOrgId === match.id) {
const full = await authClient.organization.getFullOrganization()
if (!cancelled) setOrg(full?.data ?? null)
commitOrganization(full?.data ?? null)
} else {
await setActiveOrg(savedSlug)
}
@ -189,17 +278,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const fromList = orgs.find((o) => o.id === activeOrgId)
if (fromList) {
const full = await authClient.organization.getFullOrganization()
if (!cancelled) setOrg(full?.data ?? null)
commitOrganization(full?.data ?? null)
return
}
}
const full = await authClient.organization.getFullOrganization()
if (!cancelled) setOrg(full?.data ?? null)
commitOrganization(full?.data ?? null)
} catch (error) {
console.error("Failed to restore organization:", error)
} finally {
if (!cancelled) setIsRestoring(false)
commitOrganization(null)
}
}
@ -207,7 +295,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true
}
}, [isSessionPending, session, orgsData, orgsPending, setActiveOrg])
}, [
isSessionPending,
organizations,
session?.session,
sessionScope,
setActiveOrg,
])
useEffect(() => {
if (typeof window === "undefined") return

View file

@ -23,5 +23,8 @@
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.76"
},
"devDependencies": {
"happy-dom": "^20.9.0"
}
}

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "bun:test"
import {
createAuthSessionScope,
isOAuthConsentPath,
readScopedAuthValue,
scopedAuthValueForResponse,
} from "./scoped-auth-state"
const accountAScope = createAuthSessionScope({
isPending: false,
sessionId: "session-a",
userId: "user-a",
})
const accountBScope = createAuthSessionScope({
isPending: false,
sessionId: "session-b",
userId: "user-b",
})
if (!accountAScope || !accountBScope) {
throw new Error("Test scopes must be defined")
}
describe("scoped auth state", () => {
it("hides the previous account's data immediately", () => {
const accountAOrganizations = {
scope: accountAScope,
data: [{ id: "private-account-a-org" }],
}
expect(readScopedAuthValue(accountBScope, accountAOrganizations)).toEqual({
ready: false,
})
expect(readScopedAuthValue(null, accountAOrganizations)).toEqual({
ready: false,
})
})
it("accepts only responses for the current session scope", () => {
expect(
scopedAuthValueForResponse(accountBScope, accountAScope, [
"late-account-a-org",
]),
).toBeNull()
const accountBOrganizations = scopedAuthValueForResponse(
accountBScope,
accountBScope,
["account-b-org"],
)
expect(readScopedAuthValue(accountBScope, accountBOrganizations)).toEqual({
ready: true,
data: ["account-b-org"],
})
})
it("requires a settled authenticated session", () => {
expect(
createAuthSessionScope({
isPending: true,
sessionId: "stale-session",
userId: "stale-user",
}),
).toBeNull()
expect(
createAuthSessionScope({
isPending: false,
sessionId: null,
userId: null,
}),
).toBeNull()
})
it("refetches when the same user starts a new session", () => {
const first = createAuthSessionScope({
isPending: false,
sessionId: "first-session",
userId: "same-user",
})
const second = createAuthSessionScope({
isPending: false,
sessionId: "second-session",
userId: "same-user",
})
expect(first).not.toBe(second)
})
it("serializes identifiers without delimiter collisions", () => {
const first = createAuthSessionScope({
isPending: false,
sessionId: "session|user",
userId: "scope",
})
const second = createAuthSessionScope({
isPending: false,
sessionId: "session",
userId: "user|scope",
})
expect(first).not.toBe(second)
})
it("recognizes canonical and trailing-slash OAuth consent routes", () => {
expect(isOAuthConsentPath("/oauth/consent")).toBe(true)
expect(isOAuthConsentPath("/oauth/consent/")).toBe(true)
expect(isOAuthConsentPath("/brain")).toBe(false)
})
})

View file

@ -0,0 +1,40 @@
export interface ScopedAuthValue<T> {
scope: string
data: T
}
export type ScopedAuthRead<T> = { ready: false } | { ready: true; data: T }
export function createAuthSessionScope({
isPending,
sessionId,
userId,
}: {
isPending: boolean
sessionId: string | null | undefined
userId: string | null | undefined
}): string | null {
if (isPending || !sessionId || !userId) return null
return JSON.stringify([sessionId, userId])
}
export function readScopedAuthValue<T>(
currentScope: string | null,
value: ScopedAuthValue<T> | null,
): ScopedAuthRead<T> {
if (!currentScope || value?.scope !== currentScope) return { ready: false }
return { ready: true, data: value.data }
}
export function scopedAuthValueForResponse<T>(
currentScope: string | null,
requestedScope: string,
data: T,
): ScopedAuthValue<T> | null {
if (currentScope !== requestedScope) return null
return { scope: requestedScope, data }
}
export function isOAuthConsentPath(pathname: string): boolean {
return pathname === "/oauth/consent" || pathname === "/oauth/consent/"
}