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 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: <AdvancedSettings />,
},
{
path: 'developer',
element: <DeveloperSettings />,
},
],
},
{

View File

@@ -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,
}

View File

@@ -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,7 +429,23 @@ export const useSSE = () => {
setConnectionState(SSE_STATES.CLOSED)
stopHeartbeatMonitor()
if (!isManuallyClosedRef.current) {
// Close the EventSource to prevent it from retrying on its own
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
if (isManuallyClosedRef.current) {
console.log('SSE: Manually closed, not reconnecting')
return
}
// Check if reconnect is already scheduled
if (isReconnectScheduledRef.current) {
console.log('SSE: Reconnect already scheduled, skipping')
return
}
// Check if this is a 401 unauthorized error
const is401Error =
error.status === 401 ||
@@ -429,6 +470,13 @@ export const useSSE = () => {
)
setError('Token refreshed - reconnecting...')
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
console.log(
`Processing ${apiClient.failedQueue.length} queued requests after SSE token refresh`,
)
apiClient.processQueue(null, refreshResult.token)
}
// Reset reconnect attempts since we have a fresh token
reconnectAttemptsRef.current = 0
@@ -437,22 +485,46 @@ export const useSSE = () => {
clearTimeout(reconnectTimeoutRef.current)
}
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + 1000
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
nextReconnectTimeRef.current = null
connect()
}, 1000) // Short delay to avoid rapid reconnection
}, 1000)
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
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')
// Don't attempt reconnection if token refresh failed
return
}
} catch (refreshError) {
@@ -468,10 +540,6 @@ export const useSSE = () => {
}
// Schedule reconnect for non-401 errors
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
const attemptIndex = Math.min(
reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1,
@@ -479,17 +547,22 @@ export const useSSE = () => {
const delay = RECONNECT_INTERVALS[attemptIndex]
console.log(
`Scheduling SSE reconnect in ${delay}ms (attempt ${
reconnectAttemptsRef.current + 1
})`,
`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)
showError({
@@ -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,
}),
}
}

View File

@@ -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()
try {
await logout()
} catch (e) {
console.error('Error during logout', e)
}
if (window.location.pathname !== '/login') window.location.href = '/login'
}) // fire and forget
// 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 {
// Refresh failed
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 {
// 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}`
}
}

View File

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

View File

@@ -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)
}
if (refreshTokenExpiry) {
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY, refreshTokenExpiry)
}
if (Capacitor.isNativePlatform()) {
// On native platforms, also save refresh tokens to Capacitor Preferences
if (isNativePlatform()) {
try {
if (refreshToken) {
await Preferences.set({

View File

@@ -115,7 +115,7 @@ const LoginSettings = () => {
key: 'customServerUrl',
value: serverURL,
}).then(() => {
apiClient.baseURL = serverURL + '/api/v1'
apiClient.customServerURL = serverURL + '/api/v1'
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,
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: <Settings />,
},
{
id: 'developer',
title: 'Developer Settings',
description:
'View technical information about authentication tokens, SSE connections, and debug data.',
icon: <Code />,
},
]
const handleCardClick = settingId => {