diff --git a/.github/workflows/pr-web.yaml b/.github/workflows/pr-web.yaml index fdec4768fdf..a241c725cf3 100644 --- a/.github/workflows/pr-web.yaml +++ b/.github/workflows/pr-web.yaml @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Generate API clients + run: bun run openapi-ts + - name: Lint run: bun run lint diff --git a/apps/web/src/lib/api-interceptors.ts b/apps/web/src/lib/api-interceptors.ts new file mode 100644 index 00000000000..05691a933dd --- /dev/null +++ b/apps/web/src/lib/api-interceptors.ts @@ -0,0 +1,39 @@ +/** + * Request/response interceptors for the generated HeyAPI clients. + * + * Attaches the `Vellum-Organization-Id` header and `X-CSRFToken` header + * to all outbound requests. Import this module for its side effects in + * the app entrypoint (`main.tsx`) so interceptors are installed before + * any API call fires. + * + * Reference: https://heyapi.dev/openapi-ts/clients/fetch#interceptors + */ +import { client as authClient } from "@/generated/auth/client.gen.js"; +import { client as platformClient } from "@/generated/api/client.gen.js"; +import { ensureCsrfCookie, getCsrfToken } from "@/lib/auth/csrf.js"; +import { getActiveOrganizationIdForRequests } from "@/lib/organization/organization-state.js"; + +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +async function requestInterceptor(request: Request) { + const newRequest = new Request(request); + const organizationId = getActiveOrganizationIdForRequests(); + + if (organizationId) { + newRequest.headers.set("Vellum-Organization-Id", organizationId); + } + + if (MUTATING_METHODS.has(request.method)) { + await ensureCsrfCookie(); + const csrfToken = getCsrfToken(); + if (csrfToken) { + newRequest.headers.set("X-CSRFToken", csrfToken); + } + } + + return newRequest; +} + +for (const apiClient of [authClient, platformClient]) { + apiClient.interceptors.request.use(requestInterceptor); +} diff --git a/apps/web/src/lib/auth/allauth-client.ts b/apps/web/src/lib/auth/allauth-client.ts new file mode 100644 index 00000000000..0ca26e94e18 --- /dev/null +++ b/apps/web/src/lib/auth/allauth-client.ts @@ -0,0 +1,118 @@ +/** + * Thin adapter around the generated allauth HeyAPI SDK. + * + * Normalizes responses into a discriminated union so callers + * don't have to scatter null-checks across every call site. + * + * Reference: https://docs.allauth.org/en/latest/headless/openapi-specification/ + */ +import type { + Authenticated, + EmailAddress, + Flow, + ProviderAccount, +} from "@/generated/auth/types.gen.js"; +import { + deleteAllauthByClientV1AuthSession, + getAllauthByClientV1AuthSession, + getAllauthByClientV1AuthProviderSignup, + postAllauthByClientV1AuthProviderSignup, +} from "@/generated/auth/sdk.gen.js"; + +export type AllauthResult = + | { ok: true; data: T } + | { + ok: false; + status?: number; + errors: Array<{ code: string; message: string; param?: string }>; + flows?: Array; + }; + +export function isConflict(result: AllauthResult): boolean { + return !result.ok && result.status === 409; +} + +function errorResult( + error: unknown, + status?: number, +): AllauthResult { + const err = error as Record | undefined; + if (err && Array.isArray(err.errors)) { + return { ok: false, status, errors: err.errors }; + } + const data = err?.data as Record | undefined; + if (data && Array.isArray(data.flows)) { + return { ok: false, status, errors: [], flows: data.flows as Array }; + } + return { ok: false, status, errors: [] }; +} + +export async function getSession(): Promise> { + const { data, error, response } = await getAllauthByClientV1AuthSession({ + path: { client: "browser" }, + }); + + if (data) { + return { ok: true, data: data.data }; + } + + return errorResult(error, response?.status); +} + +export async function logout(): Promise { + const { data, error, response } = + await deleteAllauthByClientV1AuthSession({ + path: { client: "browser" }, + }); + + if (data) { + return { ok: true, data }; + } + + if (response?.status === 401) { + return { ok: true, data: {} }; + } + + return errorResult(error, response?.status); +} + +export interface ProviderSignupContext { + account: ProviderAccount; + user: Authenticated["user"]; + email: Array; +} + +export async function getProviderSignup(): Promise< + AllauthResult +> { + const { data, error, response } = + await getAllauthByClientV1AuthProviderSignup({ + path: { client: "browser" }, + }); + + if (data) { + return { ok: true, data: data.data as unknown as ProviderSignupContext }; + } + + return errorResult(error, response?.status); +} + +export async function submitProviderSignup({ + email, + username, +}: { + email: string; + username: string; +}): Promise> { + const { data, error, response } = + await postAllauthByClientV1AuthProviderSignup({ + path: { client: "browser" }, + body: { email, username }, + }); + + if (data) { + return { ok: true, data: data.data }; + } + + return errorResult(error, response?.status); +} diff --git a/apps/web/src/lib/auth/auth-provider.tsx b/apps/web/src/lib/auth/auth-provider.tsx new file mode 100644 index 00000000000..41ae9df5f04 --- /dev/null +++ b/apps/web/src/lib/auth/auth-provider.tsx @@ -0,0 +1,202 @@ +/** + * Authentication context for the web SPA. + * + * Probes the Django/allauth session on mount, re-validates on window focus + * and visibility changes, and synchronizes logout across tabs via + * BroadcastChannel. + * + * Reference: https://docs.allauth.org/en/latest/headless/openapi-specification/ + */ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; + +import { + getSession, + logout as allauthLogout, +} from "@/lib/auth/allauth-client.js"; +import { setActiveOrganizationIdForRequests } from "@/lib/organization/organization-state.js"; + +export interface AuthSessionUser { + id?: string; + username?: string; + email?: string; + is_staff?: boolean; + first_name?: string; + last_name?: string; +} + +function getAuthSessionUserId(user: AuthSessionUser | null): string | null { + return user?.id ?? user?.email ?? user?.username ?? null; +} + +function syncOrganizationStateForUser( + previousUser: AuthSessionUser | null, + nextUser: AuthSessionUser | null, +): void { + const previousUserId = getAuthSessionUserId(previousUser); + const nextUserId = getAuthSessionUserId(nextUser); + + if (!nextUserId || (previousUserId && previousUserId !== nextUserId)) { + setActiveOrganizationIdForRequests(null); + } +} + +interface AuthContextType { + isLoggedIn: boolean; + isLoading: boolean; + isAdmin: boolean; + userId: string | null; + username: string | null; + email: string | null; + firstName: string; + lastName: string; + logout: () => Promise; + refreshSession: () => Promise; +} + +const AuthContext = createContext(null); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [userId, setUserId] = useState(null); + const [username, setUsername] = useState(null); + const [email, setEmail] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const previousUserRef = useRef(null); + + const setUser = useCallback( + (user: AuthSessionUser | null) => { + syncOrganizationStateForUser(previousUserRef.current, user); + previousUserRef.current = user; + + setIsLoggedIn(!!user); + setUserId(getAuthSessionUserId(user)); + setUsername(user?.username ?? null); + setEmail(user?.email ?? null); + setIsAdmin(user?.is_staff ?? false); + setFirstName(user?.first_name ?? ""); + setLastName(user?.last_name ?? ""); + }, + [], + ); + + const refreshSession = useCallback(async (): Promise => { + const result = await getSession(); + if (result.ok && result.data.user) { + setUser(result.data.user); + return true; + } + + setUser(null); + return false; + }, [setUser]); + + useEffect(() => { + let cancelled = false; + async function initSession() { + const result = await getSession(); + if (cancelled) return; + if (result.ok && result.data.user) { + setUser(result.data.user); + setIsLoading(false); + return; + } + + setUser(null); + setIsLoading(false); + } + initSession().catch((err) => { + if (cancelled) return; + console.error("auth.initSession failed", err); + setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [setUser]); + + useEffect(() => { + const safeRefresh = () => + refreshSession().catch((err) => + console.warn("auth.refreshSession failed", err), + ); + const onFocus = () => safeRefresh(); + const onVisibilityChange = () => { + if (document.visibilityState === "visible") safeRefresh(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [refreshSession]); + + const channelRef = useRef(null); + useEffect(() => { + if (typeof BroadcastChannel === "undefined") return; + const channel = new BroadcastChannel("auth"); + channelRef.current = channel; + channel.onmessage = () => { + refreshSession(); + }; + return () => { + channel.close(); + channelRef.current = null; + }; + }, [refreshSession]); + + const broadcastAuthChange = useCallback(() => { + channelRef.current?.postMessage("auth-changed"); + }, []); + + const logout = useCallback(async () => { + try { + await allauthLogout(); + } finally { + setUser(null); + broadcastAuthChange(); + } + }, [setUser, broadcastAuthChange]); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/apps/web/src/lib/auth/csrf.ts b/apps/web/src/lib/auth/csrf.ts new file mode 100644 index 00000000000..a8f3789a287 --- /dev/null +++ b/apps/web/src/lib/auth/csrf.ts @@ -0,0 +1,67 @@ +/** + * CSRF token management for Django-backed requests. + * + * The browser's cookie jar holds the CSRF token set by Django. Mutating + * requests (POST, PUT, PATCH, DELETE) must send it back in the + * `X-CSRFToken` header so Django's CSRF middleware can verify the request + * wasn't forged. + * + * Reference: https://docs.djangoproject.com/en/5.1/howto/csrf/#acquiring-the-token-if-csrf-use-sessions-and-csrf-cookie-httponly-are-not-in-use + */ +import { getAllauthByClientV1AuthSession } from "@/generated/auth/sdk.gen.js"; + +const CSRF_COOKIE_NAME = "csrftoken"; + +/** + * Read the CSRF token from the browser cookie jar. + * + * When duplicate cookies exist (e.g. a stale host-specific cookie alongside + * the domain cookie), return the last match to match Django's `SimpleCookie` + * last-wins semantics. + */ +export function getCsrfToken(): string | undefined { + const match = document.cookie + .split("; ") + .findLast((row) => row.startsWith(`${CSRF_COOKIE_NAME}=`)); + return match?.split("=").slice(1).join("="); +} + +function clearDuplicateCsrfCookies(): void { + const matches = document.cookie + .split("; ") + .filter((row) => row.startsWith(`${CSRF_COOKIE_NAME}=`)); + if (matches.length > 1) { + document.cookie = `${CSRF_COOKIE_NAME}=; path=/; max-age=0; secure`; + } +} + +let csrfBootstrap: Promise | null = null; + +export async function ensureCsrfCookie(): Promise { + clearDuplicateCsrfCookies(); + + if (getCsrfToken()) return; + + if (!csrfBootstrap) { + csrfBootstrap = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await getAllauthByClientV1AuthSession({ + path: { client: "browser" }, + }); + if (getCsrfToken()) return; + } catch { + console.warn( + `CSRF cookie bootstrap failed (attempt ${attempt + 1}/2)`, + ); + if (attempt === 0) { + await new Promise((r) => setTimeout(r, 500)); + } + } + } + })().finally(() => { + csrfBootstrap = null; + }); + } + await csrfBootstrap; +} diff --git a/apps/web/src/lib/feature-flags/feature-flag-provider.tsx b/apps/web/src/lib/feature-flags/feature-flag-provider.tsx new file mode 100644 index 00000000000..e0b2ffcddd2 --- /dev/null +++ b/apps/web/src/lib/feature-flags/feature-flag-provider.tsx @@ -0,0 +1,100 @@ +/** + * Client-side feature flag context. + * + * Flags are resolved server-side (LaunchDarkly) and injected into the + * page response. The provider makes them available to any component via + * `useAppFeatureFlags()`. + * + * Reference: https://docs.launchdarkly.com/sdk/client-side/react/react-web + */ +import { createContext, useContext, useMemo, type ReactNode } from "react"; + +export interface AppFeatureFlags { + a2aChannel: boolean; + accountDeletion: boolean; + analyzeConversation: boolean; + chatPullToRefresh: boolean; + conversationGroupsUI: boolean; + deployToVercel: boolean; + developerSettings: boolean; + doctor: boolean; + emailRootDomain: string; + homePage: boolean; + isNonProduction: boolean; + multiPlatformAssistant: boolean; + platformNotifications: boolean; + proPlanAdjust: boolean; + rollbackEnabled: boolean; + referralCodes: boolean; + referralCodesAdmin: boolean; + safeStorageLimits: boolean; + selfHostedAssistant: boolean; + settingsSleepPolicy: boolean; + sounds: boolean; + velvet: boolean; +} + +const DEFAULT_FLAGS: AppFeatureFlags = { + a2aChannel: false, + accountDeletion: false, + analyzeConversation: false, + chatPullToRefresh: false, + conversationGroupsUI: false, + deployToVercel: false, + developerSettings: false, + doctor: false, + emailRootDomain: "vellum.me", + homePage: false, + isNonProduction: false, + multiPlatformAssistant: false, + platformNotifications: false, + proPlanAdjust: false, + rollbackEnabled: false, + referralCodes: false, + referralCodesAdmin: false, + safeStorageLimits: false, + selfHostedAssistant: false, + settingsSleepPolicy: false, + sounds: false, + velvet: false, +}; + +const AppFeatureFlagContext = createContext(DEFAULT_FLAGS); + +export function AppFeatureFlagProvider({ + children, + ...flags +}: AppFeatureFlags & { children: ReactNode }) { + const value = useMemo(() => ({ ...flags }), [ + flags.a2aChannel, + flags.accountDeletion, + flags.analyzeConversation, + flags.chatPullToRefresh, + flags.conversationGroupsUI, + flags.deployToVercel, + flags.developerSettings, + flags.doctor, + flags.emailRootDomain, + flags.homePage, + flags.isNonProduction, + flags.multiPlatformAssistant, + flags.platformNotifications, + flags.proPlanAdjust, + flags.rollbackEnabled, + flags.referralCodes, + flags.referralCodesAdmin, + flags.safeStorageLimits, + flags.selfHostedAssistant, + flags.settingsSleepPolicy, + flags.sounds, + flags.velvet, + ]); + + return ( + {children} + ); +} + +export function useAppFeatureFlags(): AppFeatureFlags { + return useContext(AppFeatureFlagContext); +} diff --git a/apps/web/src/lib/organization/organization-provider.tsx b/apps/web/src/lib/organization/organization-provider.tsx new file mode 100644 index 00000000000..804b914e01f --- /dev/null +++ b/apps/web/src/lib/organization/organization-provider.tsx @@ -0,0 +1,251 @@ +/** + * Organization context provider. + * + * Fetches the list of organizations the authenticated user belongs to, + * resolves the active one (from sessionStorage, then falls back to the + * first org), and keeps the module-level request state in sync so API + * interceptors can attach the `Vellum-Organization-Id` header. + */ +import { useQuery } from "@tanstack/react-query"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; + +import { organizationsListOptions } from "@/generated/api/@tanstack/react-query.gen.js"; +import type { OrganizationRead } from "@/generated/api/types.gen.js"; +import { useAuth } from "@/lib/auth/auth-provider.js"; + +import { + getActiveOrganizationIdForRequests, + getStoredOrganizationId, + resolveActiveOrganizationId, + setActiveOrganizationIdForRequests, + subscribeToActiveOrganizationIdForRequests, +} from "@/lib/organization/organization-state.js"; + +type OrganizationStatus = "idle" | "loading" | "ready" | "error"; + +interface DeriveOrganizationStatusParams { + isOrganizationQueryEnabled: boolean; + isQueryError: boolean; + isQueryPending: boolean; + currentOrganizationId: string | null; + activeRequestOrganizationId: string | null; +} + +export function deriveOrganizationStatus({ + isOrganizationQueryEnabled, + isQueryError, + isQueryPending, + currentOrganizationId, + activeRequestOrganizationId, +}: DeriveOrganizationStatusParams): OrganizationStatus { + if (!isOrganizationQueryEnabled) return "idle"; + if (isQueryPending && !currentOrganizationId) return "loading"; + if (isQueryError) return "error"; + if (!currentOrganizationId) return "error"; + if (activeRequestOrganizationId !== currentOrganizationId) return "loading"; + return "ready"; +} + +interface ShouldClearOrganizationRequestStateParams { + isAuthLoading: boolean; + isLoggedIn: boolean; + isQueryError: boolean; +} + +export function shouldClearOrganizationRequestState({ + isAuthLoading, + isLoggedIn, + isQueryError, +}: ShouldClearOrganizationRequestStateParams): boolean { + if (isAuthLoading) return false; + return !isLoggedIn || isQueryError; +} + +interface OrganizationContextType { + organizations: OrganizationRead[]; + currentOrganizationId: string | null; + status: OrganizationStatus; + error: string | null; + setCurrentOrganizationId: (organizationId: string) => void; + refreshOrganizations: () => Promise; +} + +const OrganizationContext = createContext(null); + +interface OrganizationProviderProps { + children: ReactNode; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return "Failed to load organizations."; +} + +export function OrganizationProvider({ children }: OrganizationProviderProps) { + const { isLoggedIn, isLoading: isAuthLoading } = useAuth(); + const isOrganizationQueryEnabled = isLoggedIn && !isAuthLoading; + const [selectedOrganizationId, setSelectedOrganizationId] = useState< + string | null + >(null); + const activeRequestOrganizationId = useSyncExternalStore( + subscribeToActiveOrganizationIdForRequests, + getActiveOrganizationIdForRequests, + () => null, + ); + + const organizationsQuery = useQuery({ + ...organizationsListOptions(), + enabled: isOrganizationQueryEnabled, + retry: false, + }); + + const organizations = useMemo( + () => + isOrganizationQueryEnabled + ? (organizationsQuery.data?.results ?? []) + : [], + [isOrganizationQueryEnabled, organizationsQuery.data?.results], + ); + + const setCurrentOrganizationId = useCallback( + (organizationId: string) => { + if ( + !organizations.some((org) => org.id === organizationId) + ) { + return; + } + + setSelectedOrganizationId(organizationId); + setActiveOrganizationIdForRequests(organizationId); + }, + [organizations], + ); + + const { refetch: refetchOrganizations } = organizationsQuery; + const refreshOrganizations = useCallback(async () => { + await refetchOrganizations(); + }, [refetchOrganizations]); + + const currentOrganizationId = useMemo(() => { + const candidateOrganizationId = + selectedOrganizationId ?? + getStoredOrganizationId() ?? + getActiveOrganizationIdForRequests(); + return resolveActiveOrganizationId(organizations, candidateOrganizationId); + }, [organizations, selectedOrganizationId]); + + const status: OrganizationStatus = useMemo(() => { + return deriveOrganizationStatus({ + isOrganizationQueryEnabled, + isQueryError: organizationsQuery.isError, + isQueryPending: organizationsQuery.isPending, + currentOrganizationId, + activeRequestOrganizationId, + }); + }, [ + isOrganizationQueryEnabled, + organizationsQuery.isError, + organizationsQuery.isPending, + currentOrganizationId, + activeRequestOrganizationId, + ]); + + const error: string | null = useMemo(() => { + if (!isOrganizationQueryEnabled || organizationsQuery.isPending) { + return null; + } + if (organizationsQuery.isError) { + return getErrorMessage(organizationsQuery.error); + } + if (!currentOrganizationId) { + return "No organization available for this user."; + } + return null; + }, [ + isOrganizationQueryEnabled, + organizationsQuery.error, + organizationsQuery.isError, + organizationsQuery.isPending, + currentOrganizationId, + ]); + + useEffect(() => { + if ( + shouldClearOrganizationRequestState({ + isAuthLoading, + isLoggedIn, + isQueryError: organizationsQuery.isError, + }) + ) { + setActiveOrganizationIdForRequests(null); + return; + } + + if (!isOrganizationQueryEnabled) return; + + if (!currentOrganizationId) { + if (!organizationsQuery.isPending) { + setActiveOrganizationIdForRequests(null); + } + return; + } + + if (activeRequestOrganizationId !== currentOrganizationId) { + setActiveOrganizationIdForRequests(currentOrganizationId); + } + }, [ + isAuthLoading, + isLoggedIn, + isOrganizationQueryEnabled, + organizationsQuery.isError, + organizationsQuery.isPending, + currentOrganizationId, + activeRequestOrganizationId, + ]); + + const value = useMemo( + () => ({ + organizations, + currentOrganizationId, + status, + error, + setCurrentOrganizationId, + refreshOrganizations, + }), + [ + organizations, + currentOrganizationId, + status, + error, + setCurrentOrganizationId, + refreshOrganizations, + ], + ); + + return ( + + {children} + + ); +} + +export function useOrganization(): OrganizationContextType { + const context = useContext(OrganizationContext); + if (!context) { + throw new Error( + "useOrganization must be used within an OrganizationProvider", + ); + } + return context; +} diff --git a/apps/web/src/lib/organization/organization-state.ts b/apps/web/src/lib/organization/organization-state.ts new file mode 100644 index 00000000000..0f697b7742c --- /dev/null +++ b/apps/web/src/lib/organization/organization-state.ts @@ -0,0 +1,111 @@ +/** + * Organization ID state management. + * + * Keeps the active organization ID in module-level state (for request + * interceptors) and sessionStorage (for tab persistence). Exposes a + * `useSyncExternalStore`-compatible subscription so React components + * re-render when the active org changes. + */ + +const ACTIVE_ORGANIZATION_STORAGE_KEY = "vellum_active_organization_id"; + +let requestOrganizationId: string | null = null; +const requestOrganizationIdListeners = new Set<() => void>(); + +interface OrganizationWithId { + id: string; +} + +function getSessionStorage(): Storage | null { + if (typeof globalThis.sessionStorage === "undefined") { + return null; + } + return globalThis.sessionStorage; +} + +export function getStoredOrganizationId(): string | null { + const storage = getSessionStorage(); + if (!storage) return null; + try { + return storage.getItem(ACTIVE_ORGANIZATION_STORAGE_KEY); + } catch { + return null; + } +} + +function setStoredOrganizationId(organizationId: string): void { + const storage = getSessionStorage(); + if (!storage) return; + try { + storage.setItem(ACTIVE_ORGANIZATION_STORAGE_KEY, organizationId); + } catch { + // ignore storage failures + } +} + +function clearStoredOrganizationId(): void { + const storage = getSessionStorage(); + if (!storage) return; + try { + storage.removeItem(ACTIVE_ORGANIZATION_STORAGE_KEY); + } catch { + // ignore storage failures + } +} + +export function getActiveOrganizationIdForRequests(): string | null { + if (requestOrganizationId) { + return requestOrganizationId; + } + + const stored = getStoredOrganizationId(); + if (stored) { + requestOrganizationId = stored; + return stored; + } + + return null; +} + +export function setActiveOrganizationIdForRequests( + organizationId: string | null, +): void { + requestOrganizationId = organizationId; + + if (organizationId) { + setStoredOrganizationId(organizationId); + } else { + clearStoredOrganizationId(); + } + + requestOrganizationIdListeners.forEach((listener) => { + listener(); + }); +} + +export function subscribeToActiveOrganizationIdForRequests( + listener: () => void, +): () => void { + requestOrganizationIdListeners.add(listener); + return () => { + requestOrganizationIdListeners.delete(listener); + }; +} + +export function resolveActiveOrganizationId( + organizations: readonly T[], + storedOrganizationId: string | null | undefined, +): string | null { + if (organizations.length === 0) { + return null; + } + + if ( + storedOrganizationId && + organizations.some((org) => org.id === storedOrganizationId) + ) { + return storedOrganizationId; + } + + return organizations[0]!.id; +} diff --git a/apps/web/src/lib/providers/app-providers.tsx b/apps/web/src/lib/providers/app-providers.tsx new file mode 100644 index 00000000000..3585d9e7dfd --- /dev/null +++ b/apps/web/src/lib/providers/app-providers.tsx @@ -0,0 +1,86 @@ +/** + * Root provider composition for the web SPA. + * + * Wraps the app in Auth → Organization → scope-keyed QueryClient so that: + * 1. Auth state is available to all descendants. + * 2. Organization context resolves the active org for API headers. + * 3. The React Query cache is keyed by (user, org) — switching users or + * orgs yields a fresh cache instead of leaking stale data. + * + * Reference: https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +import { useAuth } from "@/lib/auth/auth-provider.js"; +import { + OrganizationProvider, + useOrganization, +} from "@/lib/organization/organization-provider.js"; + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + }, + }, + }); +} + +function AuthScopedQueryClientProvider({ + children, +}: { + children: ReactNode; +}) { + const [queryClient] = useState(() => createQueryClient()); + return ( + {children} + ); +} + +function RequestScopedQueryClientProvider({ + children, +}: { + children: ReactNode; +}) { + const [queryClient] = useState(() => createQueryClient()); + return ( + {children} + ); +} + +function ScopeKeyedQueryClientProvider({ + children, +}: { + children: ReactNode; +}) { + const { isLoggedIn, userId } = useAuth(); + const { currentOrganizationId } = useOrganization(); + const scopeKey = `${ + isLoggedIn ? `user:${userId ?? "unknown"}` : "anonymous" + }:org:${currentOrganizationId ?? "none"}`; + + return ( + + {children} + + ); +} + +export function AppProviders({ children }: { children: ReactNode }) { + const { isLoggedIn, userId } = useAuth(); + const authScopeKey = isLoggedIn + ? `user:${userId ?? "unknown"}` + : "anonymous"; + + return ( + + + + {children} + + + + ); +} diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts deleted file mode 100644 index 6bc5929d29a..00000000000 --- a/apps/web/src/lib/query-client.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 32ca78393fc..49fbca043bf 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,9 +1,12 @@ -import { QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RouterProvider } from "react-router"; -import { queryClient } from "./lib/query-client.js"; + +import { AuthProvider } from "@/lib/auth/auth-provider.js"; +import { AppProviders } from "@/lib/providers/app-providers.js"; import { router } from "./routes.js"; + +import "@/lib/api-interceptors.js"; import "./index.css"; const rootEl = document.getElementById("root"); @@ -11,8 +14,10 @@ if (!rootEl) throw new Error("Root element #root not found"); createRoot(rootEl).render( - - - - + + + + + + , ); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6a6a4344ef4..2507b7ef708 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "strict": true, "esModuleInterop": true,