feat: Add Developer Settings page and integrate with SSE context for debugging

refactor: Update ApiClient to support dynamic server URL and improve token handling
refactor: Modify TokenStorage to utilize Capacitor for native platforms
refactor: Adjust Fetcher to use updated ApiClient methods
refactor: Enhance SettingsOverview to include Developer Settings option
This commit is contained in:
Mo Tarbin
2026-01-18 12:54:13 -05:00
parent 7c8fa27aaf
commit 371631856a
9 changed files with 756 additions and 167 deletions

View File

@@ -5,6 +5,7 @@ import AccountSettings from '@/views/Settings/AccountSettings'
import AdvancedSettings from '@/views/Settings/AdvancedSettings' import AdvancedSettings from '@/views/Settings/AdvancedSettings'
import ChildUserSettings from '@/views/Settings/ChildUserSettings' import ChildUserSettings from '@/views/Settings/ChildUserSettings'
import CircleSettings from '@/views/Settings/CircleSettings' import CircleSettings from '@/views/Settings/CircleSettings'
import DeveloperSettings from '@/views/Settings/DeveloperSettings'
import Settings from '@/views/Settings/Settings' import Settings from '@/views/Settings/Settings'
import SettingsOverview from '@/views/Settings/SettingsOverview' import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes' import SettingsRoutes from '@/views/Settings/SettingsRoutes'
@@ -117,6 +118,10 @@ const Router = createBrowserRouter([
path: 'advanced', path: 'advanced',
element: <AdvancedSettings />, element: <AdvancedSettings />,
}, },
{
path: 'developer',
element: <DeveloperSettings />,
},
], ],
}, },
{ {

View File

@@ -1,8 +1,7 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { API_URL } from '../Config' import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
import { apiClient } from '../utils/ApiClient' import { apiClient } from '../utils/apiClient'
import { saveTokens, clearAllTokens } from '../utils/TokenStorage'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@@ -19,8 +18,7 @@ export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null) const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const navigate = useNavigate() const navigate = useNavigate()
const baseURL = apiClient.getApiURL()
const baseURL = `${API_URL}/api/v1`
const isAuthenticated = !!token const isAuthenticated = !!token
const isTokenExpired = () => { 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 () => { const fetchUser = async () => {
if (!token) return null if (!token) return null
@@ -132,7 +114,6 @@ export const AuthProvider = ({ children }) => {
isLoading, isLoading,
isAuthenticated, isAuthenticated,
login, login,
logout,
fetchUser, fetchUser,
} }

View File

@@ -6,13 +6,14 @@ import { useAlerts } from '../service/AlertsProvider'
import { useNotification } from '../service/NotificationProvider' import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/apiClient.js' import { apiClient } from '../utils/apiClient.js'
import { useAuth } from './useAuth.jsx' import { useAuth } from './useAuth.jsx'
const SSE_STATES = { const SSE_STATES = {
CONNECTING: 0, CONNECTING: 0,
OPEN: 1, OPEN: 1,
CLOSED: 2, 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 MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
@@ -31,6 +32,9 @@ export const useSSE = () => {
const isManuallyClosedRef = useRef(false) const isManuallyClosedRef = useRef(false)
const lastHeartbeatRef = useRef(Date.now()) const lastHeartbeatRef = useRef(Date.now())
const heartbeatMonitorRef = useRef(null) 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 queryClient = useQueryClient()
const { showError, showNotification } = useNotification() const { showError, showNotification } = useNotification()
@@ -48,13 +52,13 @@ export const useSSE = () => {
} }
// Get the API URL from apiManager // 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 // Build SSE URL - let backend determine circle from authenticated user
const sseUrl = `${apiUrl}/realtime/sse` const sseUrl = `${apiUrl}/realtime/sse`
return { url: sseUrl, token } return { url: sseUrl, token }
}, []) }, [token, isAuthenticated]) // Fixed: Added missing dependencies
const handleSSEMessage = useCallback( const handleSSEMessage = useCallback(
event => { event => {
@@ -205,17 +209,6 @@ export const useSSE = () => {
return { res: newChoreData } 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 break
case 'heartbeat': case 'heartbeat':
@@ -256,7 +249,7 @@ export const useSSE = () => {
return // Stop processing if JSON parsing fails return // Stop processing if JSON parsing fails
} }
}, },
[queryClient, showNotification, showError, userProfile], [queryClient, showNotification, showError, userProfile, showAlert],
) )
const stopHeartbeatMonitor = useCallback(() => { 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 // Create connect function that can be called from anywhere
const connect = useCallback(() => { const connect = useCallback(() => {
// Clear the scheduled flag when actually connecting
isReconnectScheduledRef.current = false
if (isCircuitBreakerOpen) { if (isCircuitBreakerOpen) {
console.log('SSE: Circuit breaker is open, preventing connection attempt') console.log('SSE: Circuit breaker is open, preventing connection attempt')
showError({ showError({
@@ -323,9 +348,6 @@ export const useSSE = () => {
setConnectionState(SSE_STATES.CONNECTING) setConnectionState(SSE_STATES.CONNECTING)
isManuallyClosedRef.current = false 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, { eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`, Authorization: `Bearer ${localStorage.getItem('token')}`,
@@ -333,11 +355,7 @@ export const useSSE = () => {
Accept: 'text/event-stream', Accept: 'text/event-stream',
}, },
withCredentials: true, 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, heartbeatTimeout: 120000,
// Enable silentTimeoutRetry to handle temporary network issues
silentTimeoutRetry: true, silentTimeoutRetry: true,
}) })
@@ -346,15 +364,19 @@ export const useSSE = () => {
setConnectionState(SSE_STATES.OPEN) setConnectionState(SSE_STATES.OPEN)
setError(null) setError(null)
reconnectAttemptsRef.current = 0 reconnectAttemptsRef.current = 0
nextReconnectTimeRef.current = null
isReconnectScheduledRef.current = false
lastHeartbeatRef.current = Date.now() lastHeartbeatRef.current = Date.now()
// Start heartbeat monitor // Start heartbeat monitor
if (heartbeatMonitorRef.current) { stopHeartbeatMonitor()
clearInterval(heartbeatMonitorRef.current)
}
heartbeatMonitorRef.current = setInterval(() => { heartbeatMonitorRef.current = setInterval(() => {
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current 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) { if (timeSinceLastHeartbeat > heartbeatTimeout) {
console.warn( console.warn(
@@ -371,25 +393,28 @@ export const useSSE = () => {
} }
setConnectionState(SSE_STATES.CLOSED) setConnectionState(SSE_STATES.CLOSED)
// Schedule reconnect // Calculate delay based on current attempt
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
const attemptIndex = Math.min( const attemptIndex = Math.min(
reconnectAttemptsRef.current, reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1, RECONNECT_INTERVALS.length - 1,
) )
const delay = RECONNECT_INTERVALS[attemptIndex] const delay = RECONNECT_INTERVALS[attemptIndex]
// Schedule reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
console.log( console.log(
`Scheduling SSE reconnect in ${delay}ms (attempt ${ `SSE: Scheduling heartbeat-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
reconnectAttemptsRef.current + 1
})`,
) )
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + delay
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
reconnectAttemptsRef.current++ reconnectAttemptsRef.current++
nextReconnectTimeRef.current = null
connect() connect()
}, delay) }, delay)
} }
@@ -404,91 +429,139 @@ export const useSSE = () => {
setConnectionState(SSE_STATES.CLOSED) setConnectionState(SSE_STATES.CLOSED)
stopHeartbeatMonitor() stopHeartbeatMonitor()
if (!isManuallyClosedRef.current) { // Close the EventSource to prevent it from retrying on its own
// Check if this is a 401 unauthorized error if (eventSourceRef.current) {
const is401Error = eventSourceRef.current.close()
error.status === 401 || eventSourceRef.current = null
error.error?.message?.includes('401') || }
error.error?.message?.includes('Unauthorized')
// Check if this is a timeout error specifically if (isManuallyClosedRef.current) {
const isTimeoutError = console.log('SSE: Manually closed, not reconnecting')
error.error?.message?.includes('No activity within') || return
error.error?.message?.includes('timeout') }
if (is401Error) { // Check if reconnect is already scheduled
console.log('SSE 401 error detected, attempting token refresh...') if (isReconnectScheduledRef.current) {
setError('Authentication expired - refreshing token...') console.log('SSE: Reconnect already scheduled, skipping')
return
}
try { // Check if this is a 401 unauthorized error
const refreshResult = await apiClient.refreshToken() 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( console.log(
'Token refreshed successfully, retrying SSE connection...', `Processing ${apiClient.failedQueue.length} queued requests after SSE token refresh`,
) )
setError('Token refreshed - reconnecting...') apiClient.processQueue(null, refreshResult.token)
// 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
} }
} catch (refreshError) {
console.error('Token refresh error:', refreshError) // Reset reconnect attempts since we have a fresh token
setError('Authentication error - please log in again') 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 return
} }
} else if (isTimeoutError) { } catch (refreshError) {
console.log('SSE timeout detected, attempting reconnection...') console.error('Token refresh error:', refreshError)
setError('Connection timeout - reconnecting...') setError('Authentication error - please log in again')
} else { return
setError('Connection error occurred')
} }
} else if (isTimeoutError) {
// Schedule reconnect for non-401 errors console.log('SSE timeout detected, attempting reconnection...')
if (reconnectTimeoutRef.current) { setError('Connection timeout - reconnecting...')
clearTimeout(reconnectTimeoutRef.current) } else {
} setError('Connection error occurred')
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)
} }
// 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) { } catch (err) {
console.error('Failed to create SSE connection:', err) console.error('Failed to create SSE connection:', err)
@@ -508,12 +581,14 @@ export const useSSE = () => {
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
isManuallyClosedRef.current = true isManuallyClosedRef.current = true
isReconnectScheduledRef.current = false
if (reconnectTimeoutRef.current) { if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null reconnectTimeoutRef.current = null
} }
nextReconnectTimeRef.current = null
stopHeartbeatMonitor() stopHeartbeatMonitor()
if (eventSourceRef.current) { if (eventSourceRef.current) {
@@ -539,7 +614,7 @@ export const useSSE = () => {
disconnect() disconnect()
} }
}, },
[connect, disconnect], [connect, disconnect, isAuthenticated],
) )
const isSSEEnabled = useCallback(() => { const isSSEEnabled = useCallback(() => {
@@ -551,7 +626,6 @@ export const useSSE = () => {
console.log('SSE auto-connect effect triggered') console.log('SSE auto-connect effect triggered')
console.log('Token valid:', isAuthenticated) console.log('Token valid:', isAuthenticated)
// Check if SSE is enabled in settings
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true' const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
console.log('SSE enabled in settings:', isSSEEnabledSetting) console.log('SSE enabled in settings:', isSSEEnabledSetting)
@@ -563,12 +637,10 @@ export const useSSE = () => {
disconnect() disconnect()
} }
// Cleanup on unmount
return () => { return () => {
disconnect() disconnect()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated]) // Fixed: Added isAuthenticated dependency
}, []) // Only run once on mount
// Cleanup timeouts on unmount // Cleanup timeouts on unmount
useEffect(() => { useEffect(() => {
@@ -580,9 +652,12 @@ export const useSSE = () => {
} }
}, [stopHeartbeatMonitor]) }, [stopHeartbeatMonitor])
// Update EventSource message handler when handleSSEMessage changes (e.g., when userProfile loads) // Update EventSource message handler when handleSSEMessage changes
useEffect(() => { 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') console.log('SSE: Updating message handler with latest userProfile')
eventSourceRef.current.onmessage = handleSSEMessage eventSourceRef.current.onmessage = handleSSEMessage
} }
@@ -592,21 +667,29 @@ export const useSSE = () => {
useEffect(() => { useEffect(() => {
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.hidden) { if (document.hidden) {
// App went to background, maintain connection but log the state
console.log( console.log(
'SSE: App backgrounded, maintaining connection but reducing activity', 'SSE: App backgrounded, maintaining connection but reducing activity',
) )
} else { } else {
// App came to foreground, ensure connection is active
console.log('SSE: App foregrounded, ensuring connection is active') console.log('SSE: App foregrounded, ensuring connection is active')
const isSSEEnabledSetting = const isSSEEnabledSetting =
localStorage.getItem('sse_enabled') === 'true' 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 ( if (
isAuthenticated && isAuthenticated &&
isSSEEnabledSetting && isSSEEnabledSetting &&
connectionState !== SSE_STATES.OPEN !isCurrentlyConnected &&
!isCurrentlyConnecting &&
!isReconnectScheduledRef.current
) { ) {
console.log('SSE: Reconnecting after visibility change')
connect() connect()
} }
} }
@@ -617,7 +700,7 @@ export const useSSE = () => {
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
} }
}, [connectionState, connect]) }, [connect, isAuthenticated])
return { return {
connectionState, connectionState,
@@ -629,7 +712,6 @@ export const useSSE = () => {
disconnect, disconnect,
toggleSSEEnabled, toggleSSEEnabled,
isSSEEnabled, isSSEEnabled,
// Helper function to check connection status
getConnectionStatus: () => { getConnectionStatus: () => {
switch (connectionState) { switch (connectionState) {
case SSE_STATES.CONNECTING: case SSE_STATES.CONNECTING:
@@ -641,14 +723,28 @@ export const useSSE = () => {
return 'disconnected' return 'disconnected'
} }
}, },
// Additional debugging information
getDebugInfo: () => ({ getDebugInfo: () => ({
connectionState, connectionState,
reconnectAttempts: reconnectAttemptsRef.current, reconnectAttempts: reconnectAttemptsRef.current,
isCircuitBreakerOpen, isCircuitBreakerOpen,
isReconnectScheduled: isReconnectScheduledRef.current,
lastHeartbeat: lastHeartbeatRef.current, lastHeartbeat: lastHeartbeatRef.current,
timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current, timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current,
isManuallyCloseRef: isManuallyClosedRef.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,
}), }),
} }
} }

View File

@@ -1,3 +1,4 @@
import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config' import { API_URL } from '../Config'
import { logout, RefreshToken } from './Fetcher' import { logout, RefreshToken } from './Fetcher'
import { import {
@@ -8,13 +9,38 @@ import {
class ApiClient { class ApiClient {
constructor() { constructor() {
this.baseURL = `${API_URL}/api/v1` this.customServerURL = `${API_URL}/api/v1`
this.isRefreshing = false this.isRefreshing = false
this.failedQueue = [] this.failedQueue = []
this.lastRefreshTime = 0 this.lastRefreshTime = 0
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds 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() { async refreshToken() {
// Check if refresh token is expired BEFORE attempting refresh // Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired() const refreshExpired = await isRefreshTokenExpired()
@@ -106,13 +132,19 @@ class ApiClient {
// Helper to avoid repeating cleanup code // Helper to avoid repeating cleanup code
async handleLogout() { async handleLogout() {
logout().then(async () => { await clearAllTokens()
await clearAllTokens() try {
if (window.location.pathname !== '/login') window.location.href = '/login' await logout()
}) // fire and forget } catch (e) {
console.error('Error during logout', e)
}
if (window.location.pathname !== '/login') window.location.href = '/login'
// fire and forget
} }
async request(endpoint, options = {}) { async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}` await this.init()
const url = `${this.customServerURL}${endpoint}`
const config = { const config = {
// credentials: 'include', // credentials: 'include',
...options, ...options,
@@ -151,25 +183,33 @@ class ApiClient {
// If already refreshing, just return the queued promise // If already refreshing, just return the queued promise
if (this.isRefreshing) { if (this.isRefreshing) {
console.log('Token refresh already in progress, queueing request')
return queuedPromise return queuedPromise
} }
// Check if we're within the refresh cooldown period // Attempt to refresh the token
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
}
const refreshResult = await this.refreshToken() const refreshResult = await this.refreshToken()
if (refreshResult.success) { if (refreshResult.success) {
// Process queue with success - this will retry all queued requests // Process queue with success - this will retry all queued requests
this.processQueue(null, refreshResult.token) 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 { } else {
// Refresh failed // Actual refresh failure - logout
this.processQueue(new Error(refreshResult.error), null) this.processQueue(new Error(refreshResult.error), null)
this.handleLogout() this.handleLogout()
return null return null
@@ -223,7 +263,7 @@ class ApiClient {
} }
getAssetURL(path) { getAssetURL(path) {
return `${this.baseURL}/assets/${path}` return `${this.customServerURL}/assets/${path}`
} }
} }

View File

@@ -11,7 +11,7 @@ const HEADERS = () => {
} }
const apiManager = { const apiManager = {
getApiURL: () => apiClient.baseURL, getApiURL: () => apiClient.getApiURL(),
} }
const createChore = userID => { const createChore = userID => {

View File

@@ -1,3 +1,4 @@
import { Capacitor } from '@capacitor/core'
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
// Token storage keys // Token storage keys
@@ -16,8 +17,7 @@ const isNativePlatform = () => {
if (_isNativePlatform === null) { if (_isNativePlatform === null) {
try { try {
_isNativePlatform = _isNativePlatform =
typeof window !== 'undefined' && typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
window.Capacitor?.isNativePlatform?.()
} catch (error) { } catch (error) {
console.warn('Platform detection failed, defaulting to web:', error) console.warn('Platform detection failed, defaulting to web:', error)
_isNativePlatform = false _isNativePlatform = false
@@ -48,9 +48,11 @@ export const saveTokens = async ({
if (accessTokenExpiry) { if (accessTokenExpiry) {
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry) localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry)
} }
if (refreshTokenExpiry) {
// On native platforms, also save refresh tokens to Capacitor Preferences localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY, refreshTokenExpiry)
if (isNativePlatform()) { }
if (Capacitor.isNativePlatform()) {
// On native platforms, also save refresh tokens to Capacitor Preferences
try { try {
if (refreshToken) { if (refreshToken) {
await Preferences.set({ await Preferences.set({

View File

@@ -115,7 +115,7 @@ const LoginSettings = () => {
key: 'customServerUrl', key: 'customServerUrl',
value: serverURL, value: serverURL,
}).then(() => { }).then(() => {
apiClient.baseURL = serverURL + '/api/v1' apiClient.customServerURL = serverURL + '/api/v1'
Navigate('/login') Navigate('/login')
}) })
}} }}

View File

@@ -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 (
<div className='grid gap-4 py-4' id='developer'>
<Typography level='h3'>Developer Settings</Typography>
<Divider />
<Typography level='body-md'>
View technical information about your authentication tokens and session
state. This information is useful for debugging and development
purposes.
</Typography>
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
}}
>
<Typography level='title-lg'>Authentication Tokens</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
size='sm'
variant='soft'
startDecorator={<Refresh />}
onClick={handleRefreshToken}
loading={isRefreshing}
disabled={isRefreshing || isRefreshingDirect}
>
Refresh Token
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Token />}
onClick={handleDirectRefreshToken}
loading={isRefreshingDirect}
disabled={isRefreshing || isRefreshingDirect}
>
Call Refresh Endpoint
</Button>
</Box>
</Box>
<Box>
<Typography level='title-sm' mb={1}>
Access Token
</Typography>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<Typography level='body-sm'>Time Left:</Typography>
<Chip color={getExpiryStatus(timeLeft.access)} variant='soft'>
{formatTimeLeft(timeLeft.access)}
</Chip>
</Box>
{accessTokenExpiry && (
<Typography level='body-xs' sx={{ mt: 0.5 }} color='neutral'>
Expires: {new Date(accessTokenExpiry).toLocaleString()}
</Typography>
)}
</Box>
<Divider />
<Box>
<Typography level='title-sm' mb={1}>
Refresh Token
</Typography>
{isNativePlatform ? (
<>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<Typography level='body-sm'>Time Left:</Typography>
<Chip
color={getExpiryStatus(timeLeft.refresh)}
variant='soft'
>
{formatTimeLeft(timeLeft.refresh)}
</Chip>
</Box>
{refreshTokenExpiry && (
<Typography level='body-xs' sx={{ mt: 0.5 }} color='neutral'>
Expires: {new Date(refreshTokenExpiry).toLocaleString()}
</Typography>
)}
</>
) : (
<Typography level='body-sm' color='neutral'>
Refresh tokens are managed via HTTP-only cookies on web
platform
</Typography>
)}
</Box>
</Box>
</Card>
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography level='title-lg'>Platform Information</Typography>
<Box>
<Typography level='body-sm'>
Platform:{' '}
<Chip variant='soft' size='sm'>
{isNativePlatform ? 'Native' : 'Web'}
</Chip>
</Typography>
</Box>
</Box>
</Card>
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography level='title-lg'>Server-Sent Events (SSE)</Typography>
<Box>
<Typography level='title-sm' mb={1}>
Connection Status
</Typography>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<Chip
color={
isConnected
? 'success'
: isConnecting
? 'warning'
: 'neutral'
}
variant='soft'
>
{getConnectionStatus
? getConnectionStatus().toUpperCase()
: 'Unknown'}
</Chip>
</Box>
{sseError && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
Error: {sseError}
</Typography>
)}
</Box>
<Divider />
<Box>
<Typography level='title-sm' mb={1}>
Last Event Received
</Typography>
{lastEvent ? (
<>
<Typography level='body-sm'>
Type:{' '}
<Chip variant='soft' size='sm'>
{lastEvent.type}
</Chip>
</Typography>
<Typography level='body-xs' color='neutral' sx={{ mt: 0.5 }}>
Received:{' '}
{lastEvent.timestamp
? new Date(lastEvent.timestamp).toLocaleString()
: 'N/A'}
</Typography>
</>
) : (
<Typography level='body-sm' color='neutral'>
No events received yet
</Typography>
)}
</Box>
<Divider />
<Box>
<Typography level='title-sm' mb={1}>
Heartbeat Status
</Typography>
{sseDebugInfo?.lastHeartbeat ? (
<>
<Typography level='body-sm'>
Last Heartbeat:{' '}
{new Date(sseDebugInfo.lastHeartbeat).toLocaleString()}
</Typography>
<Typography level='body-sm' sx={{ mt: 0.5 }}>
Time Since Last Heartbeat:{' '}
<Chip
variant='soft'
size='sm'
color={
timeSinceLastHeartbeat > 120000 ? 'warning' : 'success'
}
>
{formatTimeLeft(timeSinceLastHeartbeat)}
</Chip>
</Typography>
</>
) : (
<Typography level='body-sm' color='neutral'>
No heartbeat received yet
</Typography>
)}
</Box>
<Divider />
<Box>
<Typography level='title-sm' mb={1}>
Debug Information
</Typography>
{sseDebugInfo ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography level='body-sm'>
Reconnect Attempts:{' '}
<Chip variant='soft' size='sm'>
{sseDebugInfo.reconnectAttempts}
</Chip>
</Typography>
<Typography level='body-sm'>
Circuit Breaker:{' '}
<Chip
variant='soft'
size='sm'
color={sseDebugInfo.isCircuitBreakerOpen ? 'danger' : 'success'}
>
{sseDebugInfo.isCircuitBreakerOpen ? 'OPEN' : 'CLOSED'}
</Chip>
</Typography>
<Typography level='body-sm'>
Connection State:{' '}
<Chip variant='soft' size='sm'>
{sseDebugInfo.connectionState === 0
? 'CONNECTING'
: sseDebugInfo.connectionState === 1
? 'OPEN'
: 'CLOSED'}
</Chip>
</Typography>
</Box>
) : (
<Typography level='body-sm' color='neutral'>
No debug information available
</Typography>
)}
</Box>
</Box>
</Card>
</div>
)
}
export default DeveloperSettings

View File

@@ -3,6 +3,7 @@ import {
Api, Api,
ChevronRight, ChevronRight,
Circle, Circle,
Code,
FamilyRestroom, FamilyRestroom,
Notifications, Notifications,
Palette, Palette,
@@ -115,6 +116,13 @@ const SettingsOverview = () => {
'Configure webhooks, real-time updates, and other advanced features for enhanced productivity.', 'Configure webhooks, real-time updates, and other advanced features for enhanced productivity.',
icon: <Settings />, icon: <Settings />,
}, },
{
id: 'developer',
title: 'Developer Settings',
description:
'View technical information about authentication tokens, SSE connections, and debug data.',
icon: <Code />,
},
] ]
const handleCardClick = settingId => { const handleCardClick = settingId => {