@oxyhq/services
Version:
549 lines (528 loc) • 19.1 kB
JavaScript
;
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