UNPKG

@oxyhq/services

Version:

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

605 lines (585 loc) 18.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _react = require("react"); var _reactNative = require("react-native"); var _socket = _interopRequireDefault(require("socket.io-client")); var _index = require("../styles/index.js"); var _OxyContext = require("../context/OxyContext.js"); var _reactNativeQrcodeSvg = _interopRequireDefault(require("react-native-qrcode-svg")); var _OxyLogo = _interopRequireDefault(require("../components/OxyLogo.js")); var _core = require("@oxyhq/core"); var _jsxRuntime = require("react/jsx-runtime"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } /** * OxyAuthScreen - Sign in with Oxy * * This screen is used by OTHER apps in the Oxy ecosystem to authenticate users. * It presents two options: * 1. Scan QR code with Oxy Accounts app * 2. Open the Oxy Auth web flow * * Uses WebSocket for real-time authorization updates (with polling fallback). * The Oxy Accounts app is where users manage their cryptographic identity. * This screen should NOT be used within the Accounts app itself. */ const debug = (0, _core.createDebugLogger)('OxyAuthScreen'); const OXY_ACCOUNTS_WEB_URL = 'https://accounts.oxy.so'; const OXY_AUTH_WEB_URL = 'https://auth.oxy.so'; // Auth session expiration (5 minutes) const AUTH_SESSION_EXPIRY_MS = 5 * 60 * 1000; // Polling interval (fallback if socket fails) const POLLING_INTERVAL_MS = 3000; const resolveAuthWebBaseUrl = (baseURL, authWebUrl) => { if (authWebUrl) { return authWebUrl; } try { const url = new URL(baseURL); if (url.port === '3001') { url.port = '3000'; return url.origin; } if (url.hostname.startsWith('api.')) { url.hostname = `auth.${url.hostname.slice(4)}`; return url.origin; } } catch { // Ignore parsing errors, fall back to default. } return OXY_AUTH_WEB_URL; }; const resolveAuthRedirectUri = async authRedirectUri => { if (authRedirectUri) { return authRedirectUri; } try { const initialUrl = await _reactNative.Linking.getInitialURL(); if (!initialUrl) { return null; } const parsed = new URL(initialUrl); parsed.search = ''; parsed.hash = ''; return parsed.toString(); } catch { return null; } }; const getRedirectParams = url => { try { const parsed = new URL(url); const sessionId = parsed.searchParams.get('session_id') ?? undefined; const error = parsed.searchParams.get('error') ?? undefined; if (!sessionId && !error) { return null; } return { sessionId, error }; } catch { return null; } }; const OxyAuthScreen = ({ navigate, goBack, onAuthenticated, theme }) => { const themeValue = theme === 'light' || theme === 'dark' ? theme : 'light'; const colors = (0, _index.useThemeColors)(themeValue); const { oxyServices, signIn, switchSession } = (0, _OxyContext.useOxy)(); const [authSession, setAuthSession] = (0, _react.useState)(null); const [isLoading, setIsLoading] = (0, _react.useState)(true); const [error, setError] = (0, _react.useState)(null); const [isWaiting, setIsWaiting] = (0, _react.useState)(false); const [connectionType, setConnectionType] = (0, _react.useState)('socket'); const socketRef = (0, _react.useRef)(null); const pollingIntervalRef = (0, _react.useRef)(null); const isProcessingRef = (0, _react.useRef)(false); const linkingHandledRef = (0, _react.useRef)(false); // Handle successful authorization const handleAuthSuccess = (0, _react.useCallback)(async sessionId => { if (isProcessingRef.current) return; isProcessingRef.current = true; try { // Switch to the new session (this will get token, user data, and update state) if (switchSession) { const user = await switchSession(sessionId); if (onAuthenticated) { onAuthenticated(user); } } else { // Fallback if switchSession not available (shouldn't happen, but for safety) await oxyServices.getTokenBySession(sessionId); const user = await oxyServices.getUserBySession(sessionId); if (onAuthenticated) { onAuthenticated(user); } } } catch (err) { debug.error('Error completing auth:', err); setError('Authorization successful but failed to complete sign in. Please try again.'); isProcessingRef.current = false; } }, [oxyServices, switchSession, onAuthenticated]); // Connect to socket for real-time updates const connectSocket = (0, _react.useCallback)(sessionToken => { const baseURL = oxyServices.getBaseURL(); // Connect to the auth-session namespace (no authentication required) const socket = (0, _socket.default)(`${baseURL}/auth-session`, { transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: 3, reconnectionDelay: 1000 }); socketRef.current = socket; socket.on('connect', () => { debug.log('Auth socket connected'); // Join the room for this session token socket.emit('join', sessionToken); setConnectionType('socket'); }); socket.on('joined', () => { debug.log('Joined auth session room'); }); socket.on('auth_update', payload => { debug.log('Auth update received:', payload); if (payload.status === 'authorized' && payload.sessionId) { cleanup(); handleAuthSuccess(payload.sessionId); } else if (payload.status === 'cancelled') { cleanup(); setError('Authorization was denied.'); } else if (payload.status === 'expired') { cleanup(); setError('Session expired. Please try again.'); } }); socket.on('connect_error', err => { debug.log('Socket connection error, falling back to polling:', err.message); // Fall back to polling if socket fails socket.disconnect(); startPolling(sessionToken); }); socket.on('disconnect', () => { debug.log('Auth socket disconnected'); }); }, [oxyServices, handleAuthSuccess]); // Start polling for authorization (fallback) const startPolling = (0, _react.useCallback)(sessionToken => { setConnectionType('polling'); pollingIntervalRef.current = setInterval(async () => { if (isProcessingRef.current) return; try { const response = await oxyServices.makeRequest('GET', `/auth/session/status/${sessionToken}`, undefined, { cache: false }); if (response.authorized && response.sessionId) { cleanup(); handleAuthSuccess(response.sessionId); } else if (response.status === 'cancelled') { cleanup(); setError('Authorization was denied.'); } else if (response.status === 'expired') { cleanup(); setError('Session expired. Please try again.'); } } catch (err) { // Silent fail for polling - will retry debug.log('Auth polling error:', err); } }, POLLING_INTERVAL_MS); }, [oxyServices, handleAuthSuccess]); // Cleanup socket and polling const cleanup = (0, _react.useCallback)(() => { setIsWaiting(false); if (socketRef.current) { socketRef.current.disconnect(); socketRef.current = null; } if (pollingIntervalRef.current) { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } }, []); // Generate a new auth session const generateAuthSession = (0, _react.useCallback)(async () => { setIsLoading(true); setError(null); isProcessingRef.current = false; try { // Generate a unique session token for this auth request const sessionToken = generateSessionToken(); const expiresAt = Date.now() + AUTH_SESSION_EXPIRY_MS; // Register the auth session with the server await oxyServices.makeRequest('POST', '/auth/session/create', { sessionToken, expiresAt, appId: _reactNative.Platform.OS // Identifier for requesting app }, { cache: false }); setAuthSession({ sessionToken, expiresAt }); setIsWaiting(true); // Try socket first, will fall back to polling if needed connectSocket(sessionToken); } catch (err) { setError(err.message || 'Failed to create auth session'); } finally { setIsLoading(false); } }, [oxyServices, connectSocket]); // Generate a random session token const generateSessionToken = () => { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < 32; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; }; // Clean up on unmount (0, _react.useEffect)(() => { return () => { cleanup(); }; }, [cleanup]); // Initialize auth session (0, _react.useEffect)(() => { generateAuthSession(); }, []); // Check if session expired (0, _react.useEffect)(() => { if (authSession && Date.now() > authSession.expiresAt) { cleanup(); setAuthSession(null); setError('Session expired. Please try again.'); } }, [authSession, cleanup]); // Build the QR code data const getQRData = () => { if (!authSession) return ''; // Format: oxyauth://{sessionToken} return `oxyauth://${authSession.sessionToken}`; }; // Open Oxy Auth web flow const handleOpenAuth = (0, _react.useCallback)(async () => { if (!authSession) return; const authBaseUrl = resolveAuthWebBaseUrl(oxyServices.getBaseURL(), oxyServices.config?.authWebUrl); const webUrl = new URL('/authorize', authBaseUrl); webUrl.searchParams.set('token', authSession.sessionToken); const redirectUri = await resolveAuthRedirectUri(oxyServices.config?.authRedirectUri); if (redirectUri) { webUrl.searchParams.set('redirect_uri', redirectUri); } try { await _reactNative.Linking.openURL(webUrl.toString()); } catch (err) { setError('Unable to open Oxy Auth. Please try again or use the QR code.'); } }, [authSession, oxyServices]); // Refresh session const handleRefresh = (0, _react.useCallback)(() => { cleanup(); generateAuthSession(); }, [generateAuthSession, cleanup]); const handleAuthRedirect = (0, _react.useCallback)(url => { const params = getRedirectParams(url); if (!params) { return; } if (params.error) { cleanup(); setError('Authorization was denied.'); return; } if (params.sessionId) { cleanup(); handleAuthSuccess(params.sessionId); } }, [cleanup, handleAuthSuccess]); (0, _react.useEffect)(() => { if (_reactNative.Platform.OS === 'web') { return; } const subscription = _reactNative.Linking.addEventListener('url', ({ url }) => { linkingHandledRef.current = true; handleAuthRedirect(url); }); _reactNative.Linking.getInitialURL().then(url => { if (url && !linkingHandledRef.current) { handleAuthRedirect(url); } }).catch(() => { // Ignore linking errors; auth will still resolve via socket/polling. }); return () => { subscription.remove(); }; }, [handleAuthRedirect]); if (isLoading) { return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: [styles.container, { backgroundColor: colors.background }], children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, { size: "large", color: colors.primary }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.loadingText, { color: colors.secondaryText }], children: "Preparing sign in..." })] }); } if (error) { return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: [styles.container, { backgroundColor: colors.background }], children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.errorText, { color: colors.error }], children: error }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { style: [styles.button, { backgroundColor: colors.primary }], onPress: handleRefresh, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: styles.buttonText, children: "Try Again" }) })] }); } return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: [styles.container, { backgroundColor: colors.background }], children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.header, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_OxyLogo.default, { width: 48, height: 48 }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.title, { color: colors.text }], children: "Sign in with Oxy" }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.subtitle, { color: colors.secondaryText }], children: "Use your Oxy identity to sign in securely" })] }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: [styles.qrContainer, { backgroundColor: colors.inputBackground, borderColor: colors.border }], children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: styles.qrWrapper, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeQrcodeSvg.default, { value: getQRData(), size: 200, backgroundColor: "white", color: "black" }) }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.qrHint, { color: colors.secondaryText }], children: "Scan with Oxy Accounts app" })] }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.dividerContainer, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: [styles.divider, { backgroundColor: colors.border }] }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.dividerText, { color: colors.secondaryText }], children: "or" }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, { style: [styles.divider, { backgroundColor: colors.border }] })] }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, { style: [styles.button, { backgroundColor: colors.primary }], onPress: handleOpenAuth, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_OxyLogo.default, { width: 20, height: 20, fillColor: "white", style: styles.buttonIcon }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: styles.buttonText, children: "Open Oxy Auth" })] }), isWaiting && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.statusContainer, children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, { size: "small", color: colors.primary }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.statusText, { color: colors.secondaryText }], children: "Waiting for authorization..." })] }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, { style: styles.footer, children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, { style: [styles.footerText, { color: colors.secondaryText }], children: ["Don't have Oxy Accounts?", ' '] }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { onPress: () => _reactNative.Linking.openURL(OXY_ACCOUNTS_WEB_URL), children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.footerLink, { color: colors.primary }], children: "Get it here" }) })] }), goBack && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, { style: styles.cancelButton, onPress: goBack, children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { style: [styles.cancelText, { color: colors.secondaryText }], children: "Cancel" }) })] }); }; const styles = _reactNative.StyleSheet.create({ container: { flex: 1, padding: 24, alignItems: 'center', justifyContent: 'center' }, header: { alignItems: 'center', marginBottom: 32 }, title: { fontSize: 24, fontWeight: 'bold', marginTop: 16 }, subtitle: { fontSize: 14, marginTop: 8, textAlign: 'center' }, qrContainer: { padding: 24, borderRadius: 16, borderWidth: 1, alignItems: 'center' }, qrWrapper: { padding: 16, backgroundColor: 'white', borderRadius: 12 }, qrHint: { marginTop: 16, fontSize: 12 }, dividerContainer: { flexDirection: 'row', alignItems: 'center', marginVertical: 24, width: '100%' }, divider: { flex: 1, height: 1 }, dividerText: { marginHorizontal: 16, fontSize: 14 }, button: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 16, paddingHorizontal: 24, borderRadius: 12, width: '100%' }, buttonIcon: { marginRight: 10 }, buttonText: { color: 'white', fontSize: 16, fontWeight: '600' }, statusContainer: { flexDirection: 'row', alignItems: 'center', marginTop: 24 }, statusText: { marginLeft: 8, fontSize: 14 }, footer: { flexDirection: 'row', marginTop: 32 }, footerText: { fontSize: 14 }, footerLink: { fontSize: 14, fontWeight: '600' }, cancelButton: { marginTop: 16, padding: 12 }, cancelText: { fontSize: 14 }, loadingText: { marginTop: 16, fontSize: 14 }, errorText: { fontSize: 14, textAlign: 'center', marginBottom: 16 } }); var _default = exports.default = OxyAuthScreen; //# sourceMappingURL=OxyAuthScreen.js.map