diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index ea005fc..31a9636 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -5,6 +5,7 @@ import AccountSettings from '@/views/Settings/AccountSettings' import AdvancedSettings from '@/views/Settings/AdvancedSettings' import ChildUserSettings from '@/views/Settings/ChildUserSettings' import CircleSettings from '@/views/Settings/CircleSettings' +import DeveloperSettings from '@/views/Settings/DeveloperSettings' import Settings from '@/views/Settings/Settings' import SettingsOverview from '@/views/Settings/SettingsOverview' import SettingsRoutes from '@/views/Settings/SettingsRoutes' @@ -117,6 +118,10 @@ const Router = createBrowserRouter([ path: 'advanced', element: , }, + { + path: 'developer', + element: , + }, ], }, { diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index 3551708..9ebafc0 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -1,8 +1,7 @@ import { createContext, useContext, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { API_URL } from '../Config' -import { apiClient } from '../utils/ApiClient' -import { saveTokens, clearAllTokens } from '../utils/TokenStorage' +import { clearAllTokens, saveTokens } from '../utils/TokenStorage' +import { apiClient } from '../utils/apiClient' const AuthContext = createContext(null) @@ -19,8 +18,7 @@ export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) const navigate = useNavigate() - - const baseURL = `${API_URL}/api/v1` + const baseURL = apiClient.getApiURL() const isAuthenticated = !!token const isTokenExpired = () => { @@ -73,22 +71,6 @@ export const AuthProvider = ({ children }) => { } } - const logout = async () => { - setIsLoading(true) - try { - await fetch(`${baseURL}/auth/logout`, { - method: 'POST', - credentials: 'include', - }) - } catch (error) { - console.warn('Logout API call failed:', error) - } finally { - await clearAuth() - setIsLoading(false) - navigate('/login') - } - } - const fetchUser = async () => { if (!token) return null @@ -132,7 +114,6 @@ export const AuthProvider = ({ children }) => { isLoading, isAuthenticated, login, - logout, fetchUser, } diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js index 5b21273..2c56d49 100644 --- a/src/hooks/useSSE.js +++ b/src/hooks/useSSE.js @@ -6,13 +6,14 @@ import { useAlerts } from '../service/AlertsProvider' import { useNotification } from '../service/NotificationProvider' import { apiClient } from '../utils/apiClient.js' import { useAuth } from './useAuth.jsx' + const SSE_STATES = { CONNECTING: 0, OPEN: 1, CLOSED: 2, } -const RECONNECT_INTERVALS = [2000, 5000, 10000, 30000, 360000, 600000, 900000] // 2s, 5s, 10s, 30s, 6m, 10m, 15m +const RECONNECT_INTERVALS = [10000, 30000, 360000, 600000, 900000, 6000000] // 10s, 30s, 6m, 10m, 15m , 1h const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes @@ -31,6 +32,9 @@ export const useSSE = () => { const isManuallyClosedRef = useRef(false) const lastHeartbeatRef = useRef(Date.now()) const heartbeatMonitorRef = useRef(null) + const nextReconnectTimeRef = useRef(null) + // Track if reconnect is already scheduled to prevent duplicates + const isReconnectScheduledRef = useRef(false) const queryClient = useQueryClient() const { showError, showNotification } = useNotification() @@ -48,13 +52,13 @@ export const useSSE = () => { } // Get the API URL from apiManager - const apiUrl = apiClient.baseURL // e.g., "http://localhost:8080/api/v1" + const apiUrl = apiClient.getApiURL() // e.g., "http://localhost:8080/api/v1" // Build SSE URL - let backend determine circle from authenticated user const sseUrl = `${apiUrl}/realtime/sse` return { url: sseUrl, token } - }, []) + }, [token, isAuthenticated]) // Fixed: Added missing dependencies const handleSSEMessage = useCallback( event => { @@ -205,17 +209,6 @@ export const useSSE = () => { return { res: newChoreData } }, ) - - // Invalidate the specific chore that contains this subtask - // if (eventData.data.choreId) { - // queryClient.invalidateQueries(['chore', eventData.data.choreId]) - // queryClient.invalidateQueries([ - // 'choreDetails', - // eventData.data.choreId, - // ]) - // } - // Also invalidate general chores list - // queryClient.invalidateQueries(['chores']) break case 'heartbeat': @@ -256,7 +249,7 @@ export const useSSE = () => { return // Stop processing if JSON parsing fails } }, - [queryClient, showNotification, showError, userProfile], + [queryClient, showNotification, showError, userProfile, showAlert], ) const stopHeartbeatMonitor = useCallback(() => { @@ -266,8 +259,40 @@ export const useSSE = () => { } }, []) + // Centralized reconnect scheduling function to prevent duplicate scheduling + const scheduleReconnect = useCallback((delay, reason) => { + // Prevent duplicate scheduling + if (isReconnectScheduledRef.current) { + console.log('SSE: Reconnect already scheduled, skipping duplicate') + return + } + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + + console.log( + `SSE: Scheduling reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}, reason: ${reason})`, + ) + + isReconnectScheduledRef.current = true + nextReconnectTimeRef.current = Date.now() + delay + + reconnectTimeoutRef.current = setTimeout(() => { + isReconnectScheduledRef.current = false + reconnectAttemptsRef.current++ + nextReconnectTimeRef.current = null + // Note: connect will be called by the caller after this returns + // We need to trigger it here + window.dispatchEvent(new CustomEvent('sse-reconnect')) + }, delay) + }, []) + // Create connect function that can be called from anywhere const connect = useCallback(() => { + // Clear the scheduled flag when actually connecting + isReconnectScheduledRef.current = false + if (isCircuitBreakerOpen) { console.log('SSE: Circuit breaker is open, preventing connection attempt') showError({ @@ -323,9 +348,6 @@ export const useSSE = () => { setConnectionState(SSE_STATES.CONNECTING) isManuallyClosedRef.current = false - // here use EventSource polyfill with Authorization header as the native EventSource does not support headers - // the other option was to pass via query param which is less secure and also there. - // TODO: use cookie-based once/if at all i move from local storage to httpOnly cookies. eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, @@ -333,11 +355,7 @@ export const useSSE = () => { Accept: 'text/event-stream', }, withCredentials: true, - // Increase timeout to prevent premature disconnections - // Default is 45000ms (45s), increasing to 2 minutes - // TODO: send this in the resource object so it can be configured per instance heartbeatTimeout: 120000, - // Enable silentTimeoutRetry to handle temporary network issues silentTimeoutRetry: true, }) @@ -346,15 +364,19 @@ export const useSSE = () => { setConnectionState(SSE_STATES.OPEN) setError(null) reconnectAttemptsRef.current = 0 + nextReconnectTimeRef.current = null + isReconnectScheduledRef.current = false lastHeartbeatRef.current = Date.now() // Start heartbeat monitor - if (heartbeatMonitorRef.current) { - clearInterval(heartbeatMonitorRef.current) - } + stopHeartbeatMonitor() heartbeatMonitorRef.current = setInterval(() => { const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current - const heartbeatTimeout = 150000 // 2.5 minutes - should be longer than server heartbeat interval + const heartbeatTimeout = 150000 // 2.5 minutes + + console.debug( + `SSE: Heartbeat check - ${Math.round(timeSinceLastHeartbeat / 1000)}s since last heartbeat`, + ) if (timeSinceLastHeartbeat > heartbeatTimeout) { console.warn( @@ -371,25 +393,28 @@ export const useSSE = () => { } setConnectionState(SSE_STATES.CLOSED) - // Schedule reconnect - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - } - + // Calculate delay based on current attempt const attemptIndex = Math.min( reconnectAttemptsRef.current, RECONNECT_INTERVALS.length - 1, ) const delay = RECONNECT_INTERVALS[attemptIndex] + // Schedule reconnect + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + console.log( - `Scheduling SSE reconnect in ${delay}ms (attempt ${ - reconnectAttemptsRef.current + 1 - })`, + `SSE: Scheduling heartbeat-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`, ) + isReconnectScheduledRef.current = true + nextReconnectTimeRef.current = Date.now() + delay reconnectTimeoutRef.current = setTimeout(() => { + isReconnectScheduledRef.current = false reconnectAttemptsRef.current++ + nextReconnectTimeRef.current = null connect() }, delay) } @@ -404,91 +429,139 @@ export const useSSE = () => { setConnectionState(SSE_STATES.CLOSED) stopHeartbeatMonitor() - if (!isManuallyClosedRef.current) { - // Check if this is a 401 unauthorized error - const is401Error = - error.status === 401 || - error.error?.message?.includes('401') || - error.error?.message?.includes('Unauthorized') + // Close the EventSource to prevent it from retrying on its own + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } - // Check if this is a timeout error specifically - const isTimeoutError = - error.error?.message?.includes('No activity within') || - error.error?.message?.includes('timeout') + if (isManuallyClosedRef.current) { + console.log('SSE: Manually closed, not reconnecting') + return + } - if (is401Error) { - console.log('SSE 401 error detected, attempting token refresh...') - setError('Authentication expired - refreshing token...') + // Check if reconnect is already scheduled + if (isReconnectScheduledRef.current) { + console.log('SSE: Reconnect already scheduled, skipping') + return + } - try { - const refreshResult = await apiClient.refreshToken() + // Check if this is a 401 unauthorized error + const is401Error = + error.status === 401 || + error.error?.message?.includes('401') || + error.error?.message?.includes('Unauthorized') - if (refreshResult.success) { + // Check if this is a timeout error specifically + const isTimeoutError = + error.error?.message?.includes('No activity within') || + error.error?.message?.includes('timeout') + + if (is401Error) { + console.log('SSE 401 error detected, attempting token refresh...') + setError('Authentication expired - refreshing token...') + + try { + const refreshResult = await apiClient.refreshToken() + + if (refreshResult.success) { + console.log( + 'Token refreshed successfully, retrying SSE connection...', + ) + setError('Token refreshed - reconnecting...') + + if (apiClient.failedQueue && apiClient.failedQueue.length > 0) { console.log( - 'Token refreshed successfully, retrying SSE connection...', + `Processing ${apiClient.failedQueue.length} queued requests after SSE token refresh`, ) - setError('Token refreshed - reconnecting...') - - // Reset reconnect attempts since we have a fresh token - reconnectAttemptsRef.current = 0 - - // Schedule immediate reconnect with fresh token - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - } - - reconnectTimeoutRef.current = setTimeout(() => { - connect() - }, 1000) // Short delay to avoid rapid reconnection - - return // Exit early, don't use exponential backoff for 401 errors - } else { - // Check if refresh token expired - if (refreshResult.error === 'Refresh token expired') { - console.error('Refresh token expired, user must login again') - setError('Session expired - please log in again') - return // Don't attempt reconnection - } - - console.error('Token refresh failed:', refreshResult.error) - setError('Authentication failed - please log in again') - // Don't attempt reconnection if token refresh failed - return + apiClient.processQueue(null, refreshResult.token) } - } catch (refreshError) { - console.error('Token refresh error:', refreshError) - setError('Authentication error - please log in again') + + // Reset reconnect attempts since we have a fresh token + reconnectAttemptsRef.current = 0 + + // Schedule immediate reconnect with fresh token + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + + isReconnectScheduledRef.current = true + nextReconnectTimeRef.current = Date.now() + 1000 + reconnectTimeoutRef.current = setTimeout(() => { + isReconnectScheduledRef.current = false + nextReconnectTimeRef.current = null + connect() + }, 1000) + + return + } else if ( + refreshResult.error === 'Already refreshing' || + refreshResult.error === 'Refresh cooldown active' + ) { + console.log( + 'SSE: Token refresh in progress by another request, waiting...', + ) + setError('Token refresh in progress - reconnecting soon...') + + reconnectAttemptsRef.current = 0 + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + + isReconnectScheduledRef.current = true + nextReconnectTimeRef.current = Date.now() + 1500 + reconnectTimeoutRef.current = setTimeout(() => { + isReconnectScheduledRef.current = false + nextReconnectTimeRef.current = null + connect() + }, 1500) + + return + } else if (refreshResult.error === 'Refresh token expired') { + console.error('Refresh token expired, user must login again') + setError('Session expired - please log in again') + return + } else { + console.error('Token refresh failed:', refreshResult.error) + setError('Authentication failed - please log in again') return } - } else if (isTimeoutError) { - console.log('SSE timeout detected, attempting reconnection...') - setError('Connection timeout - reconnecting...') - } else { - setError('Connection error occurred') + } catch (refreshError) { + console.error('Token refresh error:', refreshError) + setError('Authentication error - please log in again') + return } - - // Schedule reconnect for non-401 errors - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - } - - const attemptIndex = Math.min( - reconnectAttemptsRef.current, - RECONNECT_INTERVALS.length - 1, - ) - const delay = RECONNECT_INTERVALS[attemptIndex] - - console.log( - `Scheduling SSE reconnect in ${delay}ms (attempt ${ - reconnectAttemptsRef.current + 1 - })`, - ) - - reconnectTimeoutRef.current = setTimeout(() => { - reconnectAttemptsRef.current++ - connect() - }, delay) + } else if (isTimeoutError) { + console.log('SSE timeout detected, attempting reconnection...') + setError('Connection timeout - reconnecting...') + } else { + setError('Connection error occurred') } + + // Schedule reconnect for non-401 errors + const attemptIndex = Math.min( + reconnectAttemptsRef.current, + RECONNECT_INTERVALS.length - 1, + ) + const delay = RECONNECT_INTERVALS[attemptIndex] + + console.log( + `SSE: Scheduling error-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`, + ) + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + + isReconnectScheduledRef.current = true + nextReconnectTimeRef.current = Date.now() + delay + reconnectTimeoutRef.current = setTimeout(() => { + isReconnectScheduledRef.current = false + reconnectAttemptsRef.current++ + nextReconnectTimeRef.current = null + connect() + }, delay) } } catch (err) { console.error('Failed to create SSE connection:', err) @@ -508,12 +581,14 @@ export const useSSE = () => { const disconnect = useCallback(() => { isManuallyClosedRef.current = true + isReconnectScheduledRef.current = false if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null } + nextReconnectTimeRef.current = null stopHeartbeatMonitor() if (eventSourceRef.current) { @@ -539,7 +614,7 @@ export const useSSE = () => { disconnect() } }, - [connect, disconnect], + [connect, disconnect, isAuthenticated], ) const isSSEEnabled = useCallback(() => { @@ -551,7 +626,6 @@ export const useSSE = () => { console.log('SSE auto-connect effect triggered') console.log('Token valid:', isAuthenticated) - // Check if SSE is enabled in settings const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true' console.log('SSE enabled in settings:', isSSEEnabledSetting) @@ -563,12 +637,10 @@ export const useSSE = () => { disconnect() } - // Cleanup on unmount return () => { disconnect() } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) // Only run once on mount + }, [isAuthenticated]) // Fixed: Added isAuthenticated dependency // Cleanup timeouts on unmount useEffect(() => { @@ -580,9 +652,12 @@ export const useSSE = () => { } }, [stopHeartbeatMonitor]) - // Update EventSource message handler when handleSSEMessage changes (e.g., when userProfile loads) + // Update EventSource message handler when handleSSEMessage changes useEffect(() => { - if (eventSourceRef.current && eventSourceRef.current.readyState === SSE_STATES.OPEN) { + if ( + eventSourceRef.current && + eventSourceRef.current.readyState === SSE_STATES.OPEN + ) { console.log('SSE: Updating message handler with latest userProfile') eventSourceRef.current.onmessage = handleSSEMessage } @@ -592,21 +667,29 @@ export const useSSE = () => { useEffect(() => { const handleVisibilityChange = () => { if (document.hidden) { - // App went to background, maintain connection but log the state console.log( 'SSE: App backgrounded, maintaining connection but reducing activity', ) } else { - // App came to foreground, ensure connection is active console.log('SSE: App foregrounded, ensuring connection is active') const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true' + + // Check actual EventSource state, not React state + const isCurrentlyConnected = + eventSourceRef.current?.readyState === SSE_STATES.OPEN + const isCurrentlyConnecting = + eventSourceRef.current?.readyState === SSE_STATES.CONNECTING + if ( isAuthenticated && isSSEEnabledSetting && - connectionState !== SSE_STATES.OPEN + !isCurrentlyConnected && + !isCurrentlyConnecting && + !isReconnectScheduledRef.current ) { + console.log('SSE: Reconnecting after visibility change') connect() } } @@ -617,7 +700,7 @@ export const useSSE = () => { return () => { document.removeEventListener('visibilitychange', handleVisibilityChange) } - }, [connectionState, connect]) + }, [connect, isAuthenticated]) return { connectionState, @@ -629,7 +712,6 @@ export const useSSE = () => { disconnect, toggleSSEEnabled, isSSEEnabled, - // Helper function to check connection status getConnectionStatus: () => { switch (connectionState) { case SSE_STATES.CONNECTING: @@ -641,14 +723,28 @@ export const useSSE = () => { return 'disconnected' } }, - // Additional debugging information getDebugInfo: () => ({ connectionState, reconnectAttempts: reconnectAttemptsRef.current, isCircuitBreakerOpen, + isReconnectScheduled: isReconnectScheduledRef.current, lastHeartbeat: lastHeartbeatRef.current, timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current, isManuallyCloseRef: isManuallyClosedRef.current, + nextReconnectTime: nextReconnectTimeRef.current, + timeUntilReconnect: nextReconnectTimeRef.current + ? nextReconnectTimeRef.current - Date.now() + : null, + reconnectIntervals: RECONNECT_INTERVALS, + currentReconnectDelay: + reconnectAttemptsRef.current < RECONNECT_INTERVALS.length + ? RECONNECT_INTERVALS[reconnectAttemptsRef.current] + : RECONNECT_INTERVALS[RECONNECT_INTERVALS.length - 1], + maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + circuitBreakerResetTime: CIRCUIT_BREAKER_RESET_TIME, + heartbeatTimeout: 120000, + heartbeatMonitorInterval: 60000, + heartbeatMonitorTimeout: 150000, }), } } diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index f4c9b18..82db51e 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -1,3 +1,4 @@ +import { Preferences } from '@capacitor/preferences' import { API_URL } from '../Config' import { logout, RefreshToken } from './Fetcher' import { @@ -8,13 +9,38 @@ import { class ApiClient { constructor() { - this.baseURL = `${API_URL}/api/v1` + this.customServerURL = `${API_URL}/api/v1` this.isRefreshing = false this.failedQueue = [] this.lastRefreshTime = 0 this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds } + async init() { + if (this.initPromise) { + return this.initPromise + } + + if (this.initialized) { + return Promise.resolve() + } + + this.initPromise = this._doInit() + return this.initPromise + } + + async _doInit() { + const { value: serverURL } = await Preferences.get({ + key: 'customServerUrl', + }) + + this.customServerURL = `${serverURL || API_URL}/api/v1` + this.initialized = true + } + getApiURL() { + return this.customServerURL + } + async refreshToken() { // Check if refresh token is expired BEFORE attempting refresh const refreshExpired = await isRefreshTokenExpired() @@ -106,13 +132,19 @@ class ApiClient { // Helper to avoid repeating cleanup code async handleLogout() { - logout().then(async () => { - await clearAllTokens() - if (window.location.pathname !== '/login') window.location.href = '/login' - }) // fire and forget + await clearAllTokens() + try { + await logout() + } catch (e) { + console.error('Error during logout', e) + } + + if (window.location.pathname !== '/login') window.location.href = '/login' + // fire and forget } async request(endpoint, options = {}) { - const url = `${this.baseURL}${endpoint}` + await this.init() + const url = `${this.customServerURL}${endpoint}` const config = { // credentials: 'include', ...options, @@ -151,25 +183,33 @@ class ApiClient { // If already refreshing, just return the queued promise if (this.isRefreshing) { + console.log('Token refresh already in progress, queueing request') return queuedPromise } - // Check if we're within the refresh cooldown period - const now = Date.now() - if (now - this.lastRefreshTime < this.refreshCooldown) { - console.warn('Token refresh attempted too soon, forcing logout') - this.processQueue(new Error('Refresh cooldown active'), null) - this.handleLogout() - return null - } - + // Attempt to refresh the token const refreshResult = await this.refreshToken() if (refreshResult.success) { // Process queue with success - this will retry all queued requests this.processQueue(null, refreshResult.token) + } else if (refreshResult.error === 'Refresh cooldown active') { + // We're in cooldown - token was just refreshed, retry with current token + console.log('Refresh cooldown - retrying with current token') + const currentToken = this.getToken() + if (currentToken) { + this.processQueue(null, currentToken) + } else { + this.processQueue(new Error('No token available'), null) + this.handleLogout() + return null + } + } else if (refreshResult.error === 'Already refreshing') { + // This shouldn't happen since we check isRefreshing above, but handle it anyway + console.log('Already refreshing - waiting for refresh to complete') + return queuedPromise } else { - // Refresh failed + // Actual refresh failure - logout this.processQueue(new Error(refreshResult.error), null) this.handleLogout() return null @@ -223,7 +263,7 @@ class ApiClient { } getAssetURL(path) { - return `${this.baseURL}/assets/${path}` + return `${this.customServerURL}/assets/${path}` } } diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 4da99b8..b82b4e4 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -11,7 +11,7 @@ const HEADERS = () => { } const apiManager = { - getApiURL: () => apiClient.baseURL, + getApiURL: () => apiClient.getApiURL(), } const createChore = userID => { diff --git a/src/utils/TokenStorage.js b/src/utils/TokenStorage.js index dbc7ad4..cae31c3 100644 --- a/src/utils/TokenStorage.js +++ b/src/utils/TokenStorage.js @@ -1,3 +1,4 @@ +import { Capacitor } from '@capacitor/core' import { Preferences } from '@capacitor/preferences' // Token storage keys @@ -16,8 +17,7 @@ const isNativePlatform = () => { if (_isNativePlatform === null) { try { _isNativePlatform = - typeof window !== 'undefined' && - window.Capacitor?.isNativePlatform?.() + typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.() } catch (error) { console.warn('Platform detection failed, defaulting to web:', error) _isNativePlatform = false @@ -48,9 +48,11 @@ export const saveTokens = async ({ if (accessTokenExpiry) { localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry) } - - // On native platforms, also save refresh tokens to Capacitor Preferences - if (isNativePlatform()) { + if (refreshTokenExpiry) { + localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY, refreshTokenExpiry) + } + if (Capacitor.isNativePlatform()) { + // On native platforms, also save refresh tokens to Capacitor Preferences try { if (refreshToken) { await Preferences.set({ diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx index 66f4266..f6d5fb5 100644 --- a/src/views/Authorization/LoginSettings.jsx +++ b/src/views/Authorization/LoginSettings.jsx @@ -115,7 +115,7 @@ const LoginSettings = () => { key: 'customServerUrl', value: serverURL, }).then(() => { - apiClient.baseURL = serverURL + '/api/v1' + apiClient.customServerURL = serverURL + '/api/v1' Navigate('/login') }) }} diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx new file mode 100644 index 0000000..7664d62 --- /dev/null +++ b/src/views/Settings/DeveloperSettings.jsx @@ -0,0 +1,537 @@ +import { Refresh, Token } from '@mui/icons-material' +import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' +import { useEffect, useState } from 'react' +import { useSSEContext } from '../../hooks/useSSEContext' +import { useNotification } from '../../service/NotificationProvider' +import { apiClient } from '../../utils/ApiClient' +import { RefreshToken } from '../../utils/Fetcher' +import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage' + +const DeveloperSettings = () => { + const { + isConnected, + isConnecting, + lastEvent, + error: sseError, + getConnectionStatus, + getDebugInfo, + } = useSSEContext() + + const [accessTokenExpiry, setAccessTokenExpiry] = useState(null) + const [refreshTokenExpiry, setRefreshTokenExpiry] = useState(null) + const [timeLeft, setTimeLeft] = useState({ + access: null, + refresh: null, + }) + const [isNativePlatform, setIsNativePlatform] = useState(false) + const [sseDebugInfo, setSSEDebugInfo] = useState(null) + const [timeSinceLastHeartbeat, setTimeSinceLastHeartbeat] = useState(null) + const [isRefreshing, setIsRefreshing] = useState(false) + const [isRefreshingDirect, setIsRefreshingDirect] = useState(false) + + const { showNotification } = useNotification() + + useEffect(() => { + setIsNativePlatform(isNative()) + + const loadTokenData = async () => { + const accessExpiry = localStorage.getItem('token_expiry') + setAccessTokenExpiry(accessExpiry) + + if (isNative()) { + const refreshExpiry = await getRefreshTokenExpiry() + setRefreshTokenExpiry(refreshExpiry) + } + } + + loadTokenData() + }, []) + + useEffect(() => { + const calculateTimeLeft = () => { + const now = new Date() + + let accessTime = null + if (accessTokenExpiry) { + const accessExpiryDate = new Date(accessTokenExpiry) + const diff = accessExpiryDate - now + accessTime = diff > 0 ? diff : 0 + } + + let refreshTime = null + if (refreshTokenExpiry) { + const refreshExpiryDate = new Date(refreshTokenExpiry) + const diff = refreshExpiryDate - now + refreshTime = diff > 0 ? diff : 0 + } + + setTimeLeft({ + access: accessTime, + refresh: refreshTime, + }) + + if (getDebugInfo) { + const debugInfo = getDebugInfo() + setSSEDebugInfo(debugInfo) + setTimeSinceLastHeartbeat(debugInfo.timeSinceLastHeartbeat) + } + } + + calculateTimeLeft() + const interval = setInterval(calculateTimeLeft, 1000) + + return () => clearInterval(interval) + }, [accessTokenExpiry, refreshTokenExpiry, getDebugInfo]) + + const formatTimeLeft = milliseconds => { + if (milliseconds === null) return 'N/A' + if (milliseconds === 0) return 'Expired' + + const totalSeconds = Math.floor(milliseconds / 1000) + const days = Math.floor(totalSeconds / 86400) + const hours = Math.floor((totalSeconds % 86400) / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + const parts = [] + if (days > 0) parts.push(`${days}d`) + if (hours > 0) parts.push(`${hours}h`) + if (minutes > 0) parts.push(`${minutes}m`) + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`) + + return parts.join(' ') + } + + const getExpiryStatus = milliseconds => { + if (milliseconds === null) return 'neutral' + if (milliseconds === 0) return 'danger' + if (milliseconds < 5 * 60 * 1000) return 'warning' // Less than 5 minutes + return 'success' + } + + const handleRefreshToken = async () => { + setIsRefreshing(true) + try { + const result = await apiClient.refreshToken() + + if (result.success) { + showNotification({ + type: 'success', + message: 'Token refreshed successfully', + }) + + // Reload token expiry data + const accessExpiry = localStorage.getItem('token_expiry') + setAccessTokenExpiry(accessExpiry) + + if (isNativePlatform) { + const refreshExpiry = await getRefreshTokenExpiry() + setRefreshTokenExpiry(refreshExpiry) + } + } else { + showNotification({ + type: 'error', + message: `Token refresh failed: ${result.error}`, + }) + } + } catch (error) { + showNotification({ + type: 'error', + message: `Token refresh error: ${error.message}`, + }) + } finally { + setIsRefreshing(false) + } + } + + const handleDirectRefreshToken = async () => { + setIsRefreshingDirect(true) + try { + const response = await RefreshToken() + + if (response.ok) { + const data = await response.json() + showNotification({ + type: 'success', + message: 'Refresh token endpoint called successfully', + }) + + // Reload token expiry data + const accessExpiry = localStorage.getItem('token_expiry') + setAccessTokenExpiry(accessExpiry) + + if (isNativePlatform) { + const refreshExpiry = await getRefreshTokenExpiry() + setRefreshTokenExpiry(refreshExpiry) + } + + console.log('Refresh token response:', data) + } else { + const error = await response.text() + showNotification({ + type: 'error', + message: `Refresh token endpoint failed: ${response.status} ${error}`, + }) + } + } catch (error) { + showNotification({ + type: 'error', + message: `Refresh token endpoint error: ${error.message}`, + }) + } finally { + setIsRefreshingDirect(false) + } + } + + return ( +
+ Developer Settings + + + View technical information about your authentication tokens and session + state. This information is useful for debugging and development + purposes. + + + + + + Authentication Tokens + + + + + + + + + Access Token + + + Time Left: + + {formatTimeLeft(timeLeft.access)} + + + {accessTokenExpiry && ( + + Expires: {new Date(accessTokenExpiry).toLocaleString()} + + )} + + + + + + + Refresh Token + + {isNativePlatform ? ( + <> + + Time Left: + + {formatTimeLeft(timeLeft.refresh)} + + + {refreshTokenExpiry && ( + + Expires: {new Date(refreshTokenExpiry).toLocaleString()} + + )} + + ) : ( + + Refresh tokens are managed via HTTP-only cookies on web platform + + )} + + + + + + + Platform Information + + + + Platform:{' '} + + {isNativePlatform ? 'Native' : 'Web'} + + + + + + + + + Server-Sent Events (SSE) + + + + Connection Status + + + + {getConnectionStatus + ? getConnectionStatus().toUpperCase() + : 'Unknown'} + + + {sseError && ( + + Error: {sseError} + + )} + + + + + + + Last Event Received + + {lastEvent ? ( + <> + + Type:{' '} + + {lastEvent.type} + + + + Received:{' '} + {lastEvent.timestamp + ? new Date(lastEvent.timestamp).toLocaleString() + : 'N/A'} + + + ) : ( + + No events received yet + + )} + + + + + + + Heartbeat Status + + {sseDebugInfo?.lastHeartbeat ? ( + <> + + Last Heartbeat:{' '} + {new Date(sseDebugInfo.lastHeartbeat).toLocaleString()} + + + Time Since Last Heartbeat:{' '} + 120000 ? 'warning' : 'success' + } + > + {formatTimeLeft(timeSinceLastHeartbeat)} + + + + ) : ( + + No heartbeat received yet + + )} + + + + + + + Reconnection Schedule + + {sseDebugInfo ? ( + + {sseDebugInfo.nextReconnectTime ? ( + <> + + Next Reconnect:{' '} + {new Date( + sseDebugInfo.nextReconnectTime, + ).toLocaleString()} + + + Time Until Reconnect:{' '} + + {formatTimeLeft(sseDebugInfo.timeUntilReconnect)} + + + + Current Delay:{' '} + + {formatTimeLeft(sseDebugInfo.currentReconnectDelay)} + + + + ) : ( + + No reconnection scheduled + + )} + + ) : ( + + No reconnection information available + + )} + + + + + + + Timeout Configuration + + {sseDebugInfo ? ( + + + Heartbeat Timeout:{' '} + + {formatTimeLeft(sseDebugInfo.heartbeatTimeout)} + + + + Monitor Interval:{' '} + + {formatTimeLeft(sseDebugInfo.heartbeatMonitorInterval)} + + + + Monitor Timeout:{' '} + + {formatTimeLeft(sseDebugInfo.heartbeatMonitorTimeout)} + + + + Circuit Breaker Reset:{' '} + + {formatTimeLeft(sseDebugInfo.circuitBreakerResetTime)} + + + + ) : ( + + No timeout information available + + )} + + + + + + + Debug Information + + {sseDebugInfo ? ( + + + Reconnect Attempts:{' '} + + {sseDebugInfo.reconnectAttempts} /{' '} + {sseDebugInfo.maxReconnectAttempts} + + + + Circuit Breaker:{' '} + + {sseDebugInfo.isCircuitBreakerOpen ? 'OPEN' : 'CLOSED'} + + + + Connection State:{' '} + + {sseDebugInfo.connectionState === 0 + ? 'CONNECTING' + : sseDebugInfo.connectionState === 1 + ? 'OPEN' + : 'CLOSED'} + + + + ) : ( + + No debug information available + + )} + + + +
+ ) +} + +export default DeveloperSettings diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx index 7d7ff02..066d4eb 100644 --- a/src/views/Settings/SettingsOverview.jsx +++ b/src/views/Settings/SettingsOverview.jsx @@ -3,6 +3,7 @@ import { Api, ChevronRight, Circle, + Code, FamilyRestroom, Notifications, Palette, @@ -115,6 +116,13 @@ const SettingsOverview = () => { 'Configure webhooks, real-time updates, and other advanced features for enhanced productivity.', icon: , }, + { + id: 'developer', + title: 'Developer Settings', + description: + 'View technical information about authentication tokens, SSE connections, and debug data.', + icon: , + }, ] const handleCardClick = settingId => {