mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge 56c8b22bbf into 3f7b9667c6
This commit is contained in:
commit
fd85449dbe
8 changed files with 829 additions and 68 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
444
packages/lib/auth-context.test.tsx
Normal file
444
packages/lib/auth-context.test.tsx
Normal 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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,5 +23,8 @@
|
|||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
packages/lib/scoped-auth-state.test.ts
Normal file
109
packages/lib/scoped-auth-state.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
40
packages/lib/scoped-auth-state.ts
Normal file
40
packages/lib/scoped-auth-state.ts
Normal 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/"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue