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..e2c0486
--- /dev/null
+++ b/src/views/Settings/DeveloperSettings.jsx
@@ -0,0 +1,457 @@
+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
+
+ }
+ onClick={handleRefreshToken}
+ loading={isRefreshing}
+ disabled={isRefreshing || isRefreshingDirect}
+ >
+ Refresh Token
+
+ }
+ onClick={handleDirectRefreshToken}
+ loading={isRefreshingDirect}
+ disabled={isRefreshing || isRefreshingDirect}
+ >
+ Call Refresh Endpoint
+
+
+
+
+
+
+ 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
+
+ )}
+
+
+
+
+
+
+ Debug Information
+
+ {sseDebugInfo ? (
+
+
+ Reconnect Attempts:{' '}
+
+ {sseDebugInfo.reconnectAttempts}
+
+
+
+ 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 => {