UNPKG

@oxyhq/services

Version:

OxyHQ Expo/React Native SDK — UI components, screens, and native features

549 lines (528 loc) 19.1 kB
"use strict"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { OxyServices } from '@oxyhq/core'; import { KeyManager } from '@oxyhq/core'; import { toast } from '../../lib/sonner'; import { useAuthStore } from "../stores/authStore.js"; import { useShallow } from 'zustand/react/shallow'; import { useSessionSocket } from "../hooks/useSessionSocket.js"; import { useLanguageManagement } from "../hooks/useLanguageManagement.js"; import { useSessionManagement } from "../hooks/useSessionManagement.js"; import { useAuthOperations } from "./hooks/useAuthOperations.js"; import { useDeviceManagement } from "../hooks/useDeviceManagement.js"; import { getStorageKeys, createPlatformStorage } from "../utils/storageHelpers.js"; import { isInvalidSessionError, isTimeoutOrNetworkError } from "../utils/errorHandlers.js"; import { showBottomSheet as globalShowBottomSheet } from "../navigation/bottomSheetManager.js"; import { useQueryClient } from '@tanstack/react-query'; import { clearQueryCache } from "../hooks/queryClient.js"; import { useAvatarPicker } from "../hooks/useAvatarPicker.js"; import { useAccountStore } from "../stores/accountStore.js"; import { logger as loggerUtil } from '@oxyhq/core'; import { useWebSSO, isWebBrowser } from "../hooks/useWebSSO.js"; import { jsx as _jsx } from "react/jsx-runtime"; const OxyContext = /*#__PURE__*/createContext(null); let cachedUseFollowHook = null; const loadUseFollowHook = () => { if (cachedUseFollowHook) { return cachedUseFollowHook; } try { // eslint-disable-next-line @typescript-eslint/no-var-requires const { useFollow } = require('../hooks/useFollow'); cachedUseFollowHook = useFollow; return cachedUseFollowHook; } catch (error) { if (__DEV__) { loggerUtil.warn('useFollow hook is not available. Please import useFollow from @oxyhq/services directly.', { component: 'OxyContext', method: 'loadUseFollowHook' }, error); } const fallback = () => { throw new Error('useFollow hook is only available in the UI bundle. Import it from @oxyhq/services.'); }; cachedUseFollowHook = fallback; return cachedUseFollowHook; } }; export const OxyProvider = ({ children, oxyServices: providedOxyServices, baseURL, authWebUrl, authRedirectUri, storageKeyPrefix = 'oxy_session', onAuthStateChange, onError }) => { const oxyServicesRef = useRef(null); if (!oxyServicesRef.current) { if (providedOxyServices) { oxyServicesRef.current = providedOxyServices; } else if (baseURL) { oxyServicesRef.current = new OxyServices({ baseURL, authWebUrl, authRedirectUri }); } else { throw new Error('Either oxyServices or baseURL must be provided to OxyContextProvider'); } } const oxyServices = oxyServicesRef.current; const { user, isAuthenticated, isLoading, error, loginSuccess, loginFailure, logoutStore } = useAuthStore(useShallow(state => ({ user: state.user, isAuthenticated: state.isAuthenticated, isLoading: state.isLoading, error: state.error, loginSuccess: state.loginSuccess, loginFailure: state.loginFailure, logoutStore: state.logout }))); const [tokenReady, setTokenReady] = useState(true); const [initialized, setInitialized] = useState(false); const setAuthState = useAuthStore.setState; const logger = useCallback((message, err) => { if (__DEV__) { console.warn(`[OxyContext] ${message}`, err); } }, []); const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]); // Simple storage initialization - no complex hook needed const storageRef = useRef(null); const [storage, setStorage] = useState(null); useEffect(() => { let mounted = true; createPlatformStorage().then(storageInstance => { if (mounted) { storageRef.current = storageInstance; setStorage(storageInstance); } }).catch(err => { if (mounted) { logger('Failed to initialize storage', err); onError?.({ message: 'Failed to initialize storage', code: 'STORAGE_INIT_ERROR', status: 500 }); } }); return () => { mounted = false; }; }, [logger, onError]); // Offline queuing is now handled by TanStack Query mutations // No need for custom offline queue const { currentLanguage, metadata: currentLanguageMetadata, languageName: currentLanguageName, nativeLanguageName: currentNativeLanguageName, setLanguage, applyLanguagePreference } = useLanguageManagement({ storage, languageKey: storageKeys.language, onError, logger }); const queryClient = useQueryClient(); const { sessions, activeSessionId, setActiveSessionId, updateSessions, switchSession, refreshSessions, clearSessionState, saveActiveSessionId, trackRemovedSession } = useSessionManagement({ oxyServices, storage, storageKeyPrefix, loginSuccess, logoutStore, applyLanguagePreference, onAuthStateChange, onError, setAuthError: message => setAuthState({ error: message }), logger, setTokenReady, queryClient }); const { signIn, logout, logoutAll } = useAuthOperations({ oxyServices, storage, sessions, activeSessionId, setActiveSessionId, updateSessions, saveActiveSessionId, clearSessionState, switchSession, applyLanguagePreference, onAuthStateChange, onError, loginSuccess, loginFailure, logoutStore, setAuthState, logger }); // Clear all account data (sessions, cache, etc.) const clearAllAccountData = useCallback(async () => { // Clear TanStack Query cache (in-memory) queryClient.clear(); // Clear persisted query cache if (storage) { try { await clearQueryCache(storage); } catch (error) { logger('Failed to clear persisted query cache', error); } } // Clear session state (sessions, activeSessionId, storage) await clearSessionState(); // Reset account store useAccountStore.getState().reset(); // Clear HTTP service cache oxyServices.clearCache(); }, [queryClient, storage, clearSessionState, logger, oxyServices]); const { getDeviceSessions, logoutAllDeviceSessions, updateDeviceName } = useDeviceManagement({ oxyServices, activeSessionId, onError, clearSessionState, logger }); const useFollowHook = loadUseFollowHook(); // Refs for mutable callbacks to avoid stale closures in restoreSessionsFromStorage (#187) const switchSessionRef = useRef(switchSession); switchSessionRef.current = switchSession; const updateSessionsRef = useRef(updateSessions); updateSessionsRef.current = updateSessions; const clearSessionStateRef = useRef(clearSessionState); clearSessionStateRef.current = clearSessionState; const restoreSessionsFromStorage = useCallback(async () => { if (!storage) { return; } setTokenReady(false); try { const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds); const storedSessionIds = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : []; const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId); const validSessions = []; if (storedSessionIds.length > 0) { for (const sessionId of storedSessionIds) { try { const validation = await oxyServices.validateSession(sessionId, { useHeaderValidation: true }); if (validation?.valid && validation.user) { const now = new Date(); validSessions.push({ sessionId, deviceId: '', expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), lastActive: now.toISOString(), userId: validation.user.id?.toString() ?? '', isCurrent: sessionId === storedActiveSessionId }); } } catch (validationError) { // Silently handle expected errors (invalid sessions, timeouts, network issues) during restoration // Only log unexpected errors if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) { logger('Session validation failed during init', validationError); } else if (__DEV__ && isTimeoutOrNetworkError(validationError)) { // Only log timeouts in dev mode for debugging loggerUtil.debug('Session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreSessionsFromStorage' }, validationError); } } } // Always persist validated sessions to storage (even empty list) // to clear stale/expired session IDs that would cause 401 loops on restart updateSessionsRef.current(validSessions, { merge: false }); } if (storedActiveSessionId) { try { await switchSessionRef.current(storedActiveSessionId); } catch (switchError) { // Silently handle expected errors (invalid sessions, timeouts, network issues) if (isInvalidSessionError(switchError)) { await storage.removeItem(storageKeys.activeSessionId); updateSessionsRef.current(validSessions.filter(session => session.sessionId !== storedActiveSessionId), { merge: false }); // Don't log expected session errors during restoration } else if (isTimeoutOrNetworkError(switchError)) { // Timeout/network error - non-critical, don't block if (__DEV__) { loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreSessionsFromStorage' }, switchError); } } else { // Only log unexpected errors logger('Active session validation error', switchError); } } } } catch (error) { if (__DEV__) { loggerUtil.error('Auth init error', error instanceof Error ? error : new Error(String(error)), { component: 'OxyContext', method: 'restoreSessionsFromStorage' }); } await clearSessionStateRef.current(); } finally { setTokenReady(true); } }, [logger, oxyServices, storage, storageKeys.activeSessionId, storageKeys.sessionIds]); useEffect(() => { if (!storage || initialized) { return; } setInitialized(true); restoreSessionsFromStorage().catch(error => { if (__DEV__) { logger('Failed to restore sessions from storage', error); } }); }, [restoreSessionsFromStorage, storage, initialized, logger]); // Web SSO: Automatically check for cross-domain session on web platforms // Also used for popup auth - updates all state and persists session const handleWebSSOSession = useCallback(async session => { if (!session?.user || !session?.sessionId) { if (__DEV__) { loggerUtil.warn('handleWebSSOSession: Invalid session', { component: 'OxyContext' }); } return; } // Set the access token on the HTTP client before updating UI state if (session.accessToken) { oxyServices.httpService.setTokens(session.accessToken); } else { await oxyServices.getTokenBySession(session.sessionId); } const clientSession = { sessionId: session.sessionId, deviceId: session.deviceId || '', expiresAt: session.expiresAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), lastActive: new Date().toISOString(), userId: session.user.id?.toString() ?? '', isCurrent: true }; updateSessions([clientSession], { merge: true }); setActiveSessionId(session.sessionId); loginSuccess(session.user); onAuthStateChange?.(session.user); // Persist to storage if (storage) { await storage.setItem(storageKeys.activeSessionId, session.sessionId); const existingIds = await storage.getItem(storageKeys.sessionIds); let sessionIds = []; try { sessionIds = existingIds ? JSON.parse(existingIds) : []; } catch {/* corrupted storage */} if (!sessionIds.includes(session.sessionId)) { sessionIds.push(session.sessionId); await storage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds)); } } }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, storage, storageKeys]); // Enable web SSO only after local storage check completes and no user found const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized; useWebSSO({ oxyServices, onSessionFound: handleWebSSOSession, onError: error => { if (__DEV__) { loggerUtil.debug('Web SSO check failed (non-critical)', { component: 'OxyContext' }, error); } }, enabled: shouldTryWebSSO }); // IdP session validation via lightweight iframe check // When user returns to tab, verify auth.oxy.so still has their session // If session is gone (cleared/logged out), clear local session too const lastIdPCheckRef = useRef(0); const pendingIdPCleanupRef = useRef(null); useEffect(() => { if (!isWebBrowser() || !user || !initialized) return; const checkIdPSession = () => { // Debounce: check at most once per 30 seconds const now = Date.now(); if (now - lastIdPCheckRef.current < 30000) return; lastIdPCheckRef.current = now; // Clean up any in-flight check before starting a new one pendingIdPCleanupRef.current?.(); // Load hidden iframe to check IdP session via postMessage const iframe = document.createElement('iframe'); iframe.style.cssText = 'display:none;width:0;height:0;border:0'; const idpOrigin = authWebUrl || 'https://auth.oxy.so'; iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`; let cleaned = false; const cleanup = () => { if (cleaned) return; cleaned = true; window.removeEventListener('message', handleMessage); iframe.remove(); }; const handleMessage = async event => { if (event.origin !== idpOrigin) return; if (event.data?.type !== 'oxy-session-check') return; cleanup(); if (!event.data.hasSession) { toast.info('Your session has ended. Please sign in again.'); await clearSessionState(); } }; window.addEventListener('message', handleMessage); document.body.appendChild(iframe); setTimeout(cleanup, 5000); // Timeout after 5s pendingIdPCleanupRef.current = cleanup; }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { checkIdPSession(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); pendingIdPCleanupRef.current?.(); pendingIdPCleanupRef.current = null; }; }, [user, initialized, clearSessionState]); const activeSession = activeSessionId ? sessions.find(session => session.sessionId === activeSessionId) : undefined; const currentDeviceId = activeSession?.deviceId ?? null; const userId = user?.id; const refreshSessionsWithUser = useCallback(() => refreshSessions(userId), [refreshSessions, userId]); const handleSessionRemoved = useCallback(sessionId => { trackRemovedSession(sessionId); }, [trackRemovedSession]); const handleRemoteSignOut = useCallback(() => { toast.info('You have been signed out remotely.'); logout().catch(remoteError => logger('Failed to process remote sign out', remoteError)); }, [logger, logout]); useSessionSocket({ userId, activeSessionId, currentDeviceId, refreshSessions: refreshSessionsWithUser, logout, clearSessionState, baseURL: oxyServices.getBaseURL(), getAccessToken: () => oxyServices.getAccessToken(), onRemoteSignOut: handleRemoteSignOut, onSessionRemoved: handleSessionRemoved }); const switchSessionForContext = useCallback(async sessionId => { await switchSession(sessionId); }, [switchSession]); // Identity management wrappers (delegate to KeyManager) const hasIdentity = useCallback(async () => { return KeyManager.hasIdentity(); }, []); const getPublicKey = useCallback(async () => { return KeyManager.getPublicKey(); }, []); // Create showBottomSheet function that uses the global function const showBottomSheetForContext = useCallback(screenOrConfig => { globalShowBottomSheet(screenOrConfig); }, []); // Avatar picker extracted into dedicated hook const { openAvatarPicker } = useAvatarPicker({ oxyServices, currentLanguage, activeSessionId, queryClient, showBottomSheet: showBottomSheetForContext }); const contextValue = useMemo(() => ({ user, sessions, activeSessionId, isAuthenticated, isLoading, isTokenReady: tokenReady, isStorageReady: storage !== null, error, currentLanguage, currentLanguageMetadata, currentLanguageName, currentNativeLanguageName, hasIdentity, getPublicKey, signIn, handlePopupSession: handleWebSSOSession, logout, logoutAll, switchSession: switchSessionForContext, removeSession: logout, refreshSessions: refreshSessionsWithUser, setLanguage, getDeviceSessions, logoutAllDeviceSessions, updateDeviceName, clearSessionState, clearAllAccountData, oxyServices, useFollow: useFollowHook, showBottomSheet: showBottomSheetForContext, openAvatarPicker }), [activeSessionId, signIn, handleWebSSOSession, currentLanguage, currentLanguageMetadata, currentLanguageName, currentNativeLanguageName, error, getDeviceSessions, getPublicKey, hasIdentity, isAuthenticated, isLoading, logout, logoutAll, logoutAllDeviceSessions, oxyServices, refreshSessionsWithUser, sessions, setLanguage, storage, switchSessionForContext, tokenReady, updateDeviceName, clearAllAccountData, useFollowHook, user, showBottomSheetForContext, openAvatarPicker]); return /*#__PURE__*/_jsx(OxyContext.Provider, { value: contextValue, children: children }); }; export const OxyContextProvider = OxyProvider; export const useOxy = () => { const context = useContext(OxyContext); if (!context) { throw new Error('useOxy must be used within an OxyContextProvider'); } return context; }; export default OxyContext; //# sourceMappingURL=OxyContext.js.map