@oxyhq/services
Version:
542 lines (523 loc) • 16.1 kB
JavaScript
"use strict";
/**
* SignInModal - Full screen sign-in modal with QR code
*
* A semi-transparent full-screen modal that displays:
* - QR code for scanning with Oxy Accounts app
* - Button to open Oxy Auth popup
*
* Animates with fade-in effect.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, Dimensions, ActivityIndicator, Platform, Linking } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import io from 'socket.io-client';
import QRCode from 'react-native-qrcode-svg';
import { useThemeColors } from "../hooks/useThemeColors.js";
import { useOxy } from "../context/OxyContext.js";
import OxyLogo from "./OxyLogo.js";
import { createDebugLogger } from '@oxyhq/core';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
const debug = createDebugLogger('SignInModal');
const {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT
} = Dimensions.get('window');
// Auth session expiration (5 minutes)
const AUTH_SESSION_EXPIRY_MS = 5 * 60 * 1000;
// Polling interval (fallback if socket fails)
const POLLING_INTERVAL_MS = 3000;
// Store for modal visibility with subscription support
let modalVisible = false;
let setModalVisibleCallback = null;
const visibilityListeners = new Set();
export const showSignInModal = () => {
modalVisible = true;
setModalVisibleCallback?.(true);
visibilityListeners.forEach(listener => listener(true));
};
export const hideSignInModal = () => {
modalVisible = false;
setModalVisibleCallback?.(false);
visibilityListeners.forEach(listener => listener(false));
};
export const isSignInModalVisible = () => modalVisible;
/** Subscribe to modal visibility changes */
export const subscribeToSignInModal = listener => {
visibilityListeners.add(listener);
return () => visibilityListeners.delete(listener);
};
const SignInModal = () => {
const [visible, setVisible] = useState(false);
const [authSession, setAuthSession] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [isWaiting, setIsWaiting] = useState(false);
const insets = useSafeAreaInsets();
const colors = useThemeColors();
const {
oxyServices,
switchSession
} = useOxy();
const socketRef = useRef(null);
const pollingIntervalRef = useRef(null);
const isProcessingRef = useRef(false);
// Animation values
const opacity = useSharedValue(0);
const scale = useSharedValue(0.9);
// Register callback
useEffect(() => {
setModalVisibleCallback = setVisible;
return () => {
setModalVisibleCallback = null;
};
}, []);
// Animate in/out
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, {
duration: 250
});
scale.value = withTiming(1, {
duration: 250
});
generateAuthSession();
} else {
opacity.value = withTiming(0, {
duration: 200
});
scale.value = withTiming(0.9, {
duration: 200
});
}
}, [visible]);
const backdropStyle = useAnimatedStyle(() => ({
opacity: opacity.value
}));
const contentStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{
scale: scale.value
}]
}));
// Handle successful authorization
const handleAuthSuccess = useCallback(async sessionId => {
if (isProcessingRef.current) return;
isProcessingRef.current = true;
try {
if (switchSession) {
await switchSession(sessionId);
} else {
await oxyServices.getTokenBySession(sessionId);
}
hideSignInModal();
} 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]);
// Cleanup socket and polling
const cleanup = useCallback(() => {
setIsWaiting(false);
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}, []);
// Connect to socket for real-time updates
const connectSocket = useCallback(sessionToken => {
const baseURL = oxyServices.getBaseURL();
const socket = io(`${baseURL}/auth-session`, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 3,
reconnectionDelay: 1000
});
socketRef.current = socket;
socket.on('connect', () => {
debug.log('Auth socket connected');
socket.emit('join', sessionToken);
});
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);
socket.disconnect();
startPolling(sessionToken);
});
}, [oxyServices, handleAuthSuccess, cleanup]);
// Start polling for authorization (fallback)
const startPolling = useCallback(sessionToken => {
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) {
debug.log('Auth polling error:', err);
}
}, POLLING_INTERVAL_MS);
}, [oxyServices, handleAuthSuccess, cleanup]);
// Generate a new auth session
const generateAuthSession = useCallback(async () => {
setIsLoading(true);
setError(null);
isProcessingRef.current = false;
try {
const sessionToken = generateSessionToken();
const expiresAt = Date.now() + AUTH_SESSION_EXPIRY_MS;
await oxyServices.makeRequest('POST', '/auth/session/create', {
sessionToken,
expiresAt,
appId: Platform.OS
}, {
cache: false
});
setAuthSession({
sessionToken,
expiresAt
});
setIsWaiting(true);
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;
};
// Build the QR code data
const getQRData = () => {
if (!authSession) return '';
return `oxyauth://${authSession.sessionToken}`;
};
// Open Oxy Auth popup
const handleOpenAuthPopup = useCallback(async () => {
if (!authSession) return;
const baseURL = oxyServices.getBaseURL();
// Resolve auth web URL
let authWebUrl = oxyServices.config?.authWebUrl;
if (!authWebUrl) {
try {
const url = new URL(baseURL);
if (url.port === '3001') {
url.port = '3000';
authWebUrl = url.origin;
} else if (url.hostname.startsWith('api.')) {
url.hostname = `auth.${url.hostname.slice(4)}`;
authWebUrl = url.origin;
}
} catch {
authWebUrl = 'https://auth.oxy.so';
}
}
authWebUrl = authWebUrl || 'https://auth.oxy.so';
const webUrl = new URL('/authorize', authWebUrl);
webUrl.searchParams.set('token', authSession.sessionToken);
if (Platform.OS === 'web') {
// Open popup window on web
const width = 500;
const height = 650;
const screenWidth = window.screen?.width ?? width;
const screenHeight = window.screen?.height ?? height;
const left = (screenWidth - width) / 2;
const top = (screenHeight - height) / 2;
window.open(webUrl.toString(), 'oxy-auth-popup', `width=${width},height=${height},left=${left},top=${top},popup=1`);
} else {
// Open in browser on native
Linking.openURL(webUrl.toString());
}
}, [authSession, oxyServices]);
// Refresh session
const handleRefresh = useCallback(() => {
cleanup();
generateAuthSession();
}, [generateAuthSession, cleanup]);
// Handle close
const handleClose = useCallback(() => {
cleanup();
hideSignInModal();
}, [cleanup]);
// Clean up on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
if (!visible) return null;
return /*#__PURE__*/_jsx(Modal, {
visible: visible,
transparent: true,
animationType: "none",
statusBarTranslucent: true,
onRequestClose: handleClose,
children: /*#__PURE__*/_jsxs(Animated.View, {
style: [styles.backdrop, backdropStyle],
children: [/*#__PURE__*/_jsx(TouchableOpacity, {
style: StyleSheet.absoluteFill,
onPress: handleClose,
activeOpacity: 1
}), /*#__PURE__*/_jsxs(Animated.View, {
style: [styles.content, contentStyle, {
paddingTop: insets.top + 20,
paddingBottom: insets.bottom + 20
}],
children: [/*#__PURE__*/_jsx(TouchableOpacity, {
style: styles.closeButton,
onPress: handleClose,
children: /*#__PURE__*/_jsx(Text, {
style: styles.closeButtonText,
children: "\xD7"
})
}), /*#__PURE__*/_jsxs(View, {
style: styles.header,
children: [/*#__PURE__*/_jsx(OxyLogo, {
width: 56,
height: 56
}), /*#__PURE__*/_jsx(Text, {
style: [styles.title, {
color: colors.text
}],
children: "Sign in with Oxy"
}), /*#__PURE__*/_jsx(Text, {
style: [styles.subtitle, {
color: colors.secondaryText
}],
children: "Scan with Oxy Accounts app or use the button below"
})]
}), isLoading ? /*#__PURE__*/_jsxs(View, {
style: styles.loadingContainer,
children: [/*#__PURE__*/_jsx(ActivityIndicator, {
size: "large",
color: colors.tint
}), /*#__PURE__*/_jsx(Text, {
style: [styles.loadingText, {
color: colors.secondaryText
}],
children: "Preparing sign in..."
})]
}) : error ? /*#__PURE__*/_jsxs(View, {
style: styles.errorContainer,
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.errorText, {
color: '#EA4335'
}],
children: error
}), /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.button, {
backgroundColor: colors.tint
}],
onPress: handleRefresh,
children: /*#__PURE__*/_jsx(Text, {
style: styles.buttonText,
children: "Try Again"
})
})]
}) : /*#__PURE__*/_jsxs(_Fragment, {
children: [/*#__PURE__*/_jsx(View, {
style: [styles.qrContainer, {
backgroundColor: 'white'
}],
children: authSession ? /*#__PURE__*/_jsx(QRCode, {
value: getQRData(),
size: 200,
backgroundColor: "white",
color: "black"
}) : /*#__PURE__*/_jsx(ActivityIndicator, {
size: "large",
color: "#d169e5"
})
}), /*#__PURE__*/_jsxs(View, {
style: styles.dividerContainer,
children: [/*#__PURE__*/_jsx(View, {
style: [styles.divider, {
backgroundColor: 'rgba(255,255,255,0.3)'
}]
}), /*#__PURE__*/_jsx(Text, {
style: [styles.dividerText, {
color: 'rgba(255,255,255,0.7)'
}],
children: "or"
}), /*#__PURE__*/_jsx(View, {
style: [styles.divider, {
backgroundColor: 'rgba(255,255,255,0.3)'
}]
})]
}), /*#__PURE__*/_jsxs(TouchableOpacity, {
style: [styles.button, {
backgroundColor: '#d169e5'
}],
onPress: handleOpenAuthPopup,
children: [/*#__PURE__*/_jsx(OxyLogo, {
width: 20,
height: 20,
fillColor: "white",
style: styles.buttonIcon
}), /*#__PURE__*/_jsx(Text, {
style: styles.buttonText,
children: "Open Oxy Auth"
})]
}), isWaiting && /*#__PURE__*/_jsxs(View, {
style: styles.statusContainer,
children: [/*#__PURE__*/_jsx(ActivityIndicator, {
size: "small",
color: "white"
}), /*#__PURE__*/_jsx(Text, {
style: styles.statusText,
children: "Waiting for authorization..."
})]
})]
})]
})]
})
});
};
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.85)',
justifyContent: 'center',
alignItems: 'center'
},
content: {
width: '100%',
maxWidth: 400,
alignItems: 'center',
paddingHorizontal: 24
},
closeButton: {
position: 'absolute',
top: 16,
right: 16,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10
},
closeButtonText: {
color: 'white',
fontSize: 28,
fontWeight: '300',
lineHeight: 32
},
header: {
alignItems: 'center',
marginBottom: 32
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginTop: 16,
color: 'white'
},
subtitle: {
fontSize: 15,
marginTop: 8,
textAlign: 'center',
color: 'rgba(255, 255, 255, 0.7)'
},
qrContainer: {
padding: 20,
borderRadius: 16
},
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,
color: 'rgba(255, 255, 255, 0.7)'
},
loadingContainer: {
alignItems: 'center',
paddingVertical: 40
},
loadingText: {
marginTop: 16,
fontSize: 14
},
errorContainer: {
alignItems: 'center',
paddingVertical: 20,
width: '100%'
},
errorText: {
fontSize: 14,
textAlign: 'center',
marginBottom: 16
}
});
export default SignInModal;
//# sourceMappingURL=SignInModal.js.map