Fix WebView session expiration with auto-refresh token

- Add refreshToken() method to automatically refresh expired tokens
- Add isTokenExpiringSoon() to check if token expires within 1 hour
- Store password in SecureStore for auto-refresh capability
- Add periodic token check every 30 minutes in WebView
- Intercept WebView navigation to login pages and refresh instead
- Re-inject fresh token into WebView localStorage after refresh
- Add metro.config.js to fix Metro bundler path resolution

This prevents the app from showing web login page when session expires.
This commit is contained in:
Sergei 2026-01-07 12:11:13 -08:00
parent 5e550f0f2b
commit 8c0be34f65
3 changed files with 212 additions and 22 deletions

View File

@ -1,13 +1,15 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
import { WebView } from 'react-native-webview'; import { WebView, WebViewNavigation } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import * as SecureStore from 'expo-secure-store';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import { AppColors, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, FontSizes, Spacing } from '@/constants/theme';
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard'; const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
// URLs that indicate session expired (login page)
const LOGIN_URL_PATTERNS = ['/login', '/auth', '/signin'];
export default function HomeScreen() { export default function HomeScreen() {
const { user } = useAuth(); const { user } = useAuth();
@ -19,27 +21,119 @@ export default function HomeScreen() {
const [userName, setUserName] = useState<string | null>(null); const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [isTokenLoaded, setIsTokenLoaded] = useState(false); const [isTokenLoaded, setIsTokenLoaded] = useState(false);
const [isRefreshingToken, setIsRefreshingToken] = useState(false);
// Load credentials from SecureStore // Load credentials and check/refresh token if needed
useEffect(() => { const loadCredentials = useCallback(async () => {
const loadCredentials = async () => { try {
try { // Check if token is expiring soon and refresh if needed
const token = await SecureStore.getItemAsync('accessToken'); const isExpiring = await api.isTokenExpiringSoon();
const user = await SecureStore.getItemAsync('userName'); if (isExpiring) {
const uid = await SecureStore.getItemAsync('userId'); console.log('Token expiring soon, refreshing...');
setAuthToken(token); const refreshResult = await api.refreshToken();
setUserName(user); if (!refreshResult.ok) {
setUserId(uid); console.log('Token refresh failed, using existing credentials');
console.log('Home: Loaded credentials for WebView:', { hasToken: !!token, user, uid }); }
} catch (err) {
console.error('Failed to load credentials:', err);
} finally {
setIsTokenLoaded(true);
} }
};
loadCredentials(); // 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 // JavaScript to inject auth token into localStorage
// Web app expects auth2 as JSON: {username, token, user_id} // Web app expects auth2 as JSON: {username, token, user_id}
const injectedJavaScript = authToken const injectedJavaScript = authToken
@ -73,10 +167,33 @@ export default function HomeScreen() {
} }
}; };
const handleNavigationStateChange = (navState: any) => { const handleNavigationStateChange = (navState: WebViewNavigation) => {
setCanGoBack(navState.canGoBack); 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 = () => { const handleError = () => {
setError('Failed to load dashboard. Please check your internet connection.'); setError('Failed to load dashboard. Please check your internet connection.');
setIsLoading(false); setIsLoading(false);
@ -155,6 +272,7 @@ export default function HomeScreen() {
onError={handleError} onError={handleError}
onHttpError={handleError} onHttpError={handleError}
onNavigationStateChange={handleNavigationStateChange} onNavigationStateChange={handleNavigationStateChange}
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
javaScriptEnabled={true} javaScriptEnabled={true}
domStorageEnabled={true} domStorageEnabled={true}
startInLoadingState={true} startInLoadingState={true}

8
metro.config.js Normal file
View File

@ -0,0 +1,8 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Explicitly set projectRoot to prevent Metro from looking at parent directories
config.projectRoot = __dirname;
module.exports = config;

View File

@ -73,10 +73,11 @@ class ApiService {
}); });
if (response.ok && response.data) { if (response.ok && response.data) {
// Save credentials to SecureStore // Save credentials to SecureStore (including password for auto-refresh)
await SecureStore.setItemAsync('accessToken', response.data.access_token); await SecureStore.setItemAsync('accessToken', response.data.access_token);
await SecureStore.setItemAsync('userId', response.data.user_id.toString()); await SecureStore.setItemAsync('userId', response.data.user_id.toString());
await SecureStore.setItemAsync('userName', username); await SecureStore.setItemAsync('userName', username);
await SecureStore.setItemAsync('userPassword', password); // Store for token refresh
await SecureStore.setItemAsync('privileges', response.data.privileges); await SecureStore.setItemAsync('privileges', response.data.privileges);
await SecureStore.setItemAsync('maxRole', response.data.max_role.toString()); await SecureStore.setItemAsync('maxRole', response.data.max_role.toString());
} }
@ -84,10 +85,73 @@ class ApiService {
return response; return response;
} }
// Refresh token using stored credentials
async refreshToken(): Promise<ApiResponse<AuthResponse>> {
try {
const userName = await SecureStore.getItemAsync('userName');
const password = await SecureStore.getItemAsync('userPassword');
if (!userName || !password) {
return { ok: false, error: { message: 'No stored credentials', code: 'NO_CREDENTIALS' } };
}
console.log('Refreshing token for user:', userName);
return await this.login(userName, password);
} catch (error) {
return {
ok: false,
error: { message: 'Failed to refresh token', code: 'REFRESH_ERROR' }
};
}
}
// Check if token is about to expire (within 1 hour)
async isTokenExpiringSoon(): Promise<boolean> {
try {
const token = await this.getToken();
if (!token) return true;
// Decode JWT to get expiration
const parts = token.split('.');
if (parts.length !== 3) return true;
const payload = JSON.parse(atob(parts[1]));
const exp = payload.exp;
if (!exp) return true;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
return (exp - now) < oneHour;
} catch {
return true;
}
}
// Get all credentials for WebView injection
async getWebViewCredentials(): Promise<{
token: string;
userName: string;
userId: string;
} | null> {
try {
const token = await SecureStore.getItemAsync('accessToken');
const userName = await SecureStore.getItemAsync('userName');
const userId = await SecureStore.getItemAsync('userId');
if (!token || !userName || !userId) return null;
return { token, userName, userId };
} catch {
return null;
}
}
async logout(): Promise<void> { async logout(): Promise<void> {
await SecureStore.deleteItemAsync('accessToken'); await SecureStore.deleteItemAsync('accessToken');
await SecureStore.deleteItemAsync('userId'); await SecureStore.deleteItemAsync('userId');
await SecureStore.deleteItemAsync('userName'); await SecureStore.deleteItemAsync('userName');
await SecureStore.deleteItemAsync('userPassword');
await SecureStore.deleteItemAsync('privileges'); await SecureStore.deleteItemAsync('privileges');
await SecureStore.deleteItemAsync('maxRole'); await SecureStore.deleteItemAsync('maxRole');
} }