UNPKG

@oxyhq/services

Version:

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

554 lines (533 loc) 20.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.useOxy = exports.default = exports.OxyProvider = exports.OxyContextProvider = void 0; var _react = _interopRequireWildcard(require("react")); var _core = require("@oxyhq/core"); var _sonner = require("../../lib/sonner"); var _authStore = require("../stores/authStore.js"); var _shallow = require("zustand/react/shallow"); var _useSessionSocket = require("../hooks/useSessionSocket.js"); var _useLanguageManagement = require("../hooks/useLanguageManagement.js"); var _useSessionManagement = require("../hooks/useSessionManagement.js"); var _useAuthOperations = require("./hooks/useAuthOperations.js"); var _useDeviceManagement = require("../hooks/useDeviceManagement.js"); var _storageHelpers = require("../utils/storageHelpers.js"); var _errorHandlers = require("../utils/errorHandlers.js"); var _bottomSheetManager = require("../navigation/bottomSheetManager.js"); var _reactQuery = require("@tanstack/react-query"); var _queryClient = require("../hooks/queryClient.js"); var _useAvatarPicker = require("../hooks/useAvatarPicker.js"); var _accountStore = require("../stores/accountStore.js"); var _useWebSSO = require("../hooks/useWebSSO.js"); var _jsxRuntime = require("react/jsx-runtime"); function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); } const OxyContext = /*#__PURE__*/(0, _react.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__) { _core.logger.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; } }; const OxyProvider = ({ children, oxyServices: providedOxyServices, baseURL, authWebUrl, authRedirectUri, storageKeyPrefix = 'oxy_session', onAuthStateChange, onError }) => { const oxyServicesRef = (0, _react.useRef)(null); if (!oxyServicesRef.current) { if (providedOxyServices) { oxyServicesRef.current = providedOxyServices; } else if (baseURL) { oxyServicesRef.current = new _core.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 } = (0, _authStore.useAuthStore)((0, _shallow.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] = (0, _react.useState)(true); const [initialized, setInitialized] = (0, _react.useState)(false); const setAuthState = _authStore.useAuthStore.setState; const logger = (0, _react.useCallback)((message, err) => { if (__DEV__) { console.warn(`[OxyContext] ${message}`, err); } }, []); const storageKeys = (0, _react.useMemo)(() => (0, _storageHelpers.getStorageKeys)(storageKeyPrefix), [storageKeyPrefix]); // Simple storage initialization - no complex hook needed const storageRef = (0, _react.useRef)(null); const [storage, setStorage] = (0, _react.useState)(null); (0, _react.useEffect)(() => { let mounted = true; (0, _storageHelpers.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 } = (0, _useLanguageManagement.useLanguageManagement)({ storage, languageKey: storageKeys.language, onError, logger }); const queryClient = (0, _reactQuery.useQueryClient)(); const { sessions, activeSessionId, setActiveSessionId, updateSessions, switchSession, refreshSessions, clearSessionState, saveActiveSessionId, trackRemovedSession } = (0, _useSessionManagement.useSessionManagement)({ oxyServices, storage, storageKeyPrefix, loginSuccess, logoutStore, applyLanguagePreference, onAuthStateChange, onError, setAuthError: message => setAuthState({ error: message }), logger, setTokenReady, queryClient }); const { signIn, logout, logoutAll } = (0, _useAuthOperations.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 = (0, _react.useCallback)(async () => { // Clear TanStack Query cache (in-memory) queryClient.clear(); // Clear persisted query cache if (storage) { try { await (0, _queryClient.clearQueryCache)(storage); } catch (error) { logger('Failed to clear persisted query cache', error); } } // Clear session state (sessions, activeSessionId, storage) await clearSessionState(); // Reset account store _accountStore.useAccountStore.getState().reset(); // Clear HTTP service cache oxyServices.clearCache(); }, [queryClient, storage, clearSessionState, logger, oxyServices]); const { getDeviceSessions, logoutAllDeviceSessions, updateDeviceName } = (0, _useDeviceManagement.useDeviceManagement)({ oxyServices, activeSessionId, onError, clearSessionState, logger }); const useFollowHook = loadUseFollowHook(); // Refs for mutable callbacks to avoid stale closures in restoreSessionsFromStorage (#187) const switchSessionRef = (0, _react.useRef)(switchSession); switchSessionRef.current = switchSession; const updateSessionsRef = (0, _react.useRef)(updateSessions); updateSessionsRef.current = updateSessions; const clearSessionStateRef = (0, _react.useRef)(clearSessionState); clearSessionStateRef.current = clearSessionState; const restoreSessionsFromStorage = (0, _react.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 (!(0, _errorHandlers.isInvalidSessionError)(validationError) && !(0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) { logger('Session validation failed during init', validationError); } else if (__DEV__ && (0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) { // Only log timeouts in dev mode for debugging _core.logger.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 ((0, _errorHandlers.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 ((0, _errorHandlers.isTimeoutOrNetworkError)(switchError)) { // Timeout/network error - non-critical, don't block if (__DEV__) { _core.logger.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__) { _core.logger.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]); (0, _react.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 = (0, _react.useCallback)(async session => { if (!session?.user || !session?.sessionId) { if (__DEV__) { _core.logger.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 = (0, _useWebSSO.isWebBrowser)() && tokenReady && !user && initialized; (0, _useWebSSO.useWebSSO)({ oxyServices, onSessionFound: handleWebSSOSession, onError: error => { if (__DEV__) { _core.logger.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 = (0, _react.useRef)(0); const pendingIdPCleanupRef = (0, _react.useRef)(null); (0, _react.useEffect)(() => { if (!(0, _useWebSSO.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) { _sonner.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 = (0, _react.useCallback)(() => refreshSessions(userId), [refreshSessions, userId]); const handleSessionRemoved = (0, _react.useCallback)(sessionId => { trackRemovedSession(sessionId); }, [trackRemovedSession]); const handleRemoteSignOut = (0, _react.useCallback)(() => { _sonner.toast.info('You have been signed out remotely.'); logout().catch(remoteError => logger('Failed to process remote sign out', remoteError)); }, [logger, logout]); (0, _useSessionSocket.useSessionSocket)({ userId, activeSessionId, currentDeviceId, refreshSessions: refreshSessionsWithUser, logout, clearSessionState, baseURL: oxyServices.getBaseURL(), getAccessToken: () => oxyServices.getAccessToken(), onRemoteSignOut: handleRemoteSignOut, onSessionRemoved: handleSessionRemoved }); const switchSessionForContext = (0, _react.useCallback)(async sessionId => { await switchSession(sessionId); }, [switchSession]); // Identity management wrappers (delegate to KeyManager) const hasIdentity = (0, _react.useCallback)(async () => { return _core.KeyManager.hasIdentity(); }, []); const getPublicKey = (0, _react.useCallback)(async () => { return _core.KeyManager.getPublicKey(); }, []); // Create showBottomSheet function that uses the global function const showBottomSheetForContext = (0, _react.useCallback)(screenOrConfig => { (0, _bottomSheetManager.showBottomSheet)(screenOrConfig); }, []); // Avatar picker extracted into dedicated hook const { openAvatarPicker } = (0, _useAvatarPicker.useAvatarPicker)({ oxyServices, currentLanguage, activeSessionId, queryClient, showBottomSheet: showBottomSheetForContext }); const contextValue = (0, _react.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__*/(0, _jsxRuntime.jsx)(OxyContext.Provider, { value: contextValue, children: children }); }; exports.OxyProvider = OxyProvider; const OxyContextProvider = exports.OxyContextProvider = OxyProvider; const useOxy = () => { const context = (0, _react.useContext)(OxyContext); if (!context) { throw new Error('useOxy must be used within an OxyContextProvider'); } return context; }; exports.useOxy = useOxy; var _default = exports.default = OxyContext; //# sourceMappingURL=OxyContext.js.map