Sergei 173c0a8262 Add session expired detection and auto-refresh in WebView
- Monitor page content for "session expired" patterns
- Send message to React Native when detected
- Auto-refresh token and reload WebView
- Add logging to refreshToken for debugging
2026-01-18 22:48:41 -08:00

463 lines
15 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity, Image } from 'react-native';
import { WebView, WebViewNavigation } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import { AppColors, FontSizes, Spacing } from '@/constants/theme';
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
// URLs that indicate session expired (login page)
const LOGIN_URL_PATTERNS = ['/login', '/auth', '/signin'];
// Text patterns that indicate session expired (shown in page content)
const SESSION_EXPIRED_PATTERNS = ['session expired', 'session has expired', 'token expired', 'please log in'];
export default function HomeScreen() {
const { user } = useAuth();
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [canGoBack, setCanGoBack] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [isTokenLoaded, setIsTokenLoaded] = useState(false);
const [isRefreshingToken, setIsRefreshingToken] = useState(false);
// Load credentials and check/refresh token if needed
const loadCredentials = useCallback(async () => {
try {
// Check if token is expiring soon and refresh if needed
const isExpiring = await api.isTokenExpiringSoon();
if (isExpiring) {
console.log('Token expiring soon, refreshing...');
const refreshResult = await api.refreshToken();
if (!refreshResult.ok) {
console.log('Token refresh failed, using existing credentials');
}
}
// Get credentials (possibly refreshed)
const credentials = await api.getWebViewCredentials();
if (credentials) {
setAuthToken(credentials.token);
setUserName(credentials.userName);
setUserId(credentials.userId);
console.log('Home: Loaded credentials for WebView:', {
hasToken: !!credentials.token,
user: credentials.userName,
uid: credentials.userId
});
}
} catch (err) {
console.error('Failed to load credentials:', err);
} finally {
setIsTokenLoaded(true);
}
}, []);
useEffect(() => {
loadCredentials();
// Periodically check and refresh token (every 30 minutes)
const tokenCheckInterval = setInterval(async () => {
const isExpiring = await api.isTokenExpiringSoon();
if (isExpiring) {
console.log('Periodic check: Token expiring, refreshing...');
const result = await api.refreshToken();
if (result.ok) {
const credentials = await api.getWebViewCredentials();
if (credentials) {
setAuthToken(credentials.token);
// Re-inject token into WebView
const injectScript = `
(function() {
var authData = {
username: '${credentials.userName}',
token: '${credentials.token}',
user_id: ${credentials.userId}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Token auto-refreshed');
})();
true;
`;
webViewRef.current?.injectJavaScript(injectScript);
}
}
}
}, 30 * 60 * 1000); // 30 minutes
return () => clearInterval(tokenCheckInterval);
}, [loadCredentials]);
// Handle token refresh when WebView detects session expired
const handleTokenRefresh = useCallback(async () => {
if (isRefreshingToken) return;
setIsRefreshingToken(true);
console.log('WebView session expired, refreshing token...');
try {
const refreshResult = await api.refreshToken();
if (refreshResult.ok) {
// Get new credentials
const credentials = await api.getWebViewCredentials();
if (credentials) {
setAuthToken(credentials.token);
setUserName(credentials.userName);
setUserId(credentials.userId);
// Inject new token and reload
const injectScript = `
(function() {
var authData = {
username: '${credentials.userName}',
token: '${credentials.token}',
user_id: ${credentials.userId}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth refreshed:', authData.username);
window.location.href = '${DASHBOARD_URL}';
})();
true;
`;
webViewRef.current?.injectJavaScript(injectScript);
}
} else {
console.error('Token refresh failed');
setError('Session expired. Please restart the app.');
}
} catch (err) {
console.error('Error refreshing token:', err);
} finally {
setIsRefreshingToken(false);
}
}, [isRefreshingToken]);
// JavaScript to inject auth token into localStorage and monitor for session expiry
// Web app expects auth2 as JSON: {username, token, user_id}
const injectedJavaScript = authToken
? `
(function() {
try {
// Inject auth data
var authData = {
username: '${userName || ''}',
token: '${authToken}',
user_id: ${userId || 'null'}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth injected:', authData.username);
// Monitor page content for session expired messages
var sessionExpiredPatterns = ${JSON.stringify(SESSION_EXPIRED_PATTERNS)};
function checkForSessionExpired() {
var bodyText = (document.body?.innerText || '').toLowerCase();
for (var i = 0; i < sessionExpiredPatterns.length; i++) {
if (bodyText.includes(sessionExpiredPatterns[i])) {
console.log('Session expired detected in page content');
window.ReactNativeWebView?.postMessage(JSON.stringify({ type: 'SESSION_EXPIRED' }));
return true;
}
}
return false;
}
// Check after page loads and periodically
setTimeout(checkForSessionExpired, 1000);
setTimeout(checkForSessionExpired, 3000);
// Also observe DOM changes for dynamic content
var observer = new MutationObserver(function() {
checkForSessionExpired();
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
}
} catch(e) {
console.error('Failed to inject token:', e);
}
})();
true;
`
: '';
const handleRefresh = () => {
setError(null);
setIsLoading(true);
webViewRef.current?.reload();
};
const handleWebViewBack = () => {
if (canGoBack) {
webViewRef.current?.goBack();
}
};
const handleNavigationStateChange = (navState: WebViewNavigation) => {
setCanGoBack(navState.canGoBack);
// Check if WebView is trying to navigate to login page (session expired)
const url = navState.url?.toLowerCase() || '';
const isLoginPage = LOGIN_URL_PATTERNS.some(pattern => url.includes(pattern));
if (isLoginPage && !isRefreshingToken) {
console.log('Detected navigation to login page, refreshing token...');
handleTokenRefresh();
}
};
// Intercept navigation requests to prevent loading login page
const handleShouldStartLoadWithRequest = useCallback((request: { url: string }) => {
const url = request.url?.toLowerCase() || '';
const isLoginPage = LOGIN_URL_PATTERNS.some(pattern => url.includes(pattern));
if (isLoginPage) {
console.log('Blocking navigation to login page, refreshing token instead');
handleTokenRefresh();
return false; // Block the navigation
}
return true; // Allow all other navigations
}, [handleTokenRefresh]);
const handleError = () => {
setError('Failed to load dashboard. Please check your internet connection.');
setIsLoading(false);
};
// Handle messages from WebView (session expired detection)
const handleWebViewMessage = useCallback((event: { nativeEvent: { data: string } }) => {
try {
const message = JSON.parse(event.nativeEvent.data);
if (message.type === 'SESSION_EXPIRED') {
console.log('WebView reported session expired, refreshing token...');
handleTokenRefresh();
}
} catch {
// Ignore non-JSON messages
}
}, [handleTokenRefresh]);
// Wait for token to load
if (!isTokenLoaded) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<Image
source={require('@/assets/images/icon.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.greeting}>Hello, {user?.user_name || 'User'}</Text>
</View>
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Preparing dashboard...</Text>
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<Image
source={require('@/assets/images/icon.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.greeting}>Hello, {user?.user_name || 'User'}</Text>
</View>
<TouchableOpacity style={styles.refreshButton} onPress={handleRefresh}>
<Ionicons name="refresh" size={22} color={AppColors.primary} />
</TouchableOpacity>
</View>
<View style={styles.errorContainer}>
<Ionicons name="cloud-offline-outline" size={64} color={AppColors.textMuted} />
<Text style={styles.errorTitle}>Connection Error</Text>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<Image
source={require('@/assets/images/icon.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.greeting}>Hello, {user?.user_name || 'User'}</Text>
</View>
<View style={styles.headerActions}>
{canGoBack && (
<TouchableOpacity style={styles.actionButton} onPress={handleWebViewBack}>
<Ionicons name="chevron-back" size={22} color={AppColors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity style={styles.actionButton} onPress={handleRefresh}>
<Ionicons name="refresh" size={22} color={AppColors.primary} />
</TouchableOpacity>
</View>
</View>
{/* WebView Dashboard */}
<View style={styles.webViewContainer}>
<WebView
ref={webViewRef}
source={{ uri: DASHBOARD_URL }}
style={styles.webView}
onLoadStart={() => setIsLoading(true)}
onLoadEnd={() => setIsLoading(false)}
onError={handleError}
onHttpError={handleError}
onNavigationStateChange={handleNavigationStateChange}
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
onMessage={handleWebViewMessage}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={true}
allowsBackForwardNavigationGestures={true}
injectedJavaScriptBeforeContentLoaded={injectedJavaScript}
injectedJavaScript={injectedJavaScript}
renderLoading={() => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading dashboard...</Text>
</View>
)}
/>
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={AppColors.primary} />
</View>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
backgroundColor: AppColors.background,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
logo: {
width: 36,
height: 36,
},
greeting: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
headerTitle: {
fontSize: FontSizes.xl,
fontWeight: '700',
color: AppColors.textPrimary,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
padding: Spacing.xs,
marginLeft: Spacing.xs,
},
refreshButton: {
padding: Spacing.xs,
},
webViewContainer: {
flex: 1,
},
webView: {
flex: 1,
},
loadingContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.background,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.8)',
},
loadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.xl,
},
errorTitle: {
fontSize: FontSizes.lg,
fontWeight: '600',
color: AppColors.textPrimary,
marginTop: Spacing.md,
},
errorText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginTop: Spacing.xs,
},
retryButton: {
marginTop: Spacing.lg,
paddingHorizontal: Spacing.xl,
paddingVertical: Spacing.md,
backgroundColor: AppColors.primary,
borderRadius: 8,
},
retryButtonText: {
color: AppColors.white,
fontSize: FontSizes.base,
fontWeight: '600',
},
});