@oxyhq/services
Version:
554 lines (533 loc) • 20.6 kB
JavaScript
;
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