fix: enhance SSE error handling and token refresh logic; improve user profile checks in MyChores component
This commit is contained in:
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useAlerts } from '../service/AlertsProvider'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/apiClient'
|
||||
import { apiClient } from '../utils/apiClient.js'
|
||||
import { useAuth } from './useAuth.jsx'
|
||||
const SSE_STATES = {
|
||||
CONNECTING: 0,
|
||||
@@ -38,7 +38,11 @@ export const useSSE = () => {
|
||||
const getSSEUrl = useCallback(() => {
|
||||
const authToken = token
|
||||
if (!authToken || !isAuthenticated) {
|
||||
console.log('SSE: No valid authentication token')
|
||||
console.log(
|
||||
'SSE: No valid authentication token',
|
||||
authToken,
|
||||
isAuthenticated,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -67,6 +71,27 @@ export const useSSE = () => {
|
||||
// Handle different event types and update React Query cache accordingly
|
||||
switch (eventData.type) {
|
||||
case 'chore.created':
|
||||
showNotification({
|
||||
type: 'info',
|
||||
title: 'New Task Created',
|
||||
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
|
||||
duration: 5000,
|
||||
})
|
||||
const newChore = eventData.data.chore
|
||||
|
||||
// Update individual chore cache
|
||||
queryClient.setQueryData(['chore', newChore.id], {
|
||||
res: newChore,
|
||||
})
|
||||
|
||||
// Update chores list cache
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData || !oldData.res) {
|
||||
return { res: [newChore] }
|
||||
}
|
||||
return { res: [newChore, ...oldData.res] }
|
||||
})
|
||||
break
|
||||
case 'chore.updated':
|
||||
case 'chore.completed':
|
||||
case 'chore.status':
|
||||
@@ -300,7 +325,7 @@ export const useSSE = () => {
|
||||
// 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 ${sseConfig.token}`,
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
'Cache-Control': 'no-cache',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
@@ -371,25 +396,68 @@ export const useSSE = () => {
|
||||
|
||||
eventSourceRef.current.onmessage = handleSSEMessage
|
||||
|
||||
eventSourceRef.current.onerror = error => {
|
||||
eventSourceRef.current.onerror = async error => {
|
||||
console.error('SSE error:', error)
|
||||
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')
|
||||
|
||||
// Check if this is a timeout error specifically
|
||||
const isTimeoutError =
|
||||
error.error?.message?.includes('No activity within') ||
|
||||
error.error?.message?.includes('timeout')
|
||||
|
||||
if (isTimeoutError) {
|
||||
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...')
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
setError('Authentication error - please log in again')
|
||||
return
|
||||
}
|
||||
} else if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
}
|
||||
|
||||
// Schedule reconnect
|
||||
// Schedule reconnect for non-401 errors
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,51 @@ class ApiClient {
|
||||
this.baseURL = `${API_URL}/api/v1`
|
||||
this.isRefreshing = false
|
||||
this.failedQueue = []
|
||||
this.lastRefreshTime = 0
|
||||
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
if (this.isRefreshing) {
|
||||
return { success: false, error: 'Already refreshing' }
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
const now = Date.now()
|
||||
if (now - this.lastRefreshTime < this.refreshCooldown) {
|
||||
return { success: false, error: 'Refresh cooldown active' }
|
||||
}
|
||||
|
||||
this.isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshReq = await RefreshToken()
|
||||
|
||||
if (refreshReq.ok) {
|
||||
const data = await refreshReq.json()
|
||||
const newToken = data.token || data.access_token
|
||||
|
||||
// Update Local Storage
|
||||
localStorage.setItem('token', newToken)
|
||||
if (data.expire || data.access_token_expiry) {
|
||||
localStorage.setItem(
|
||||
'token_expiry',
|
||||
data.expire || data.access_token_expiry,
|
||||
)
|
||||
}
|
||||
|
||||
// Update last refresh time
|
||||
this.lastRefreshTime = Date.now()
|
||||
|
||||
return { success: true, token: newToken }
|
||||
} else {
|
||||
return { success: false, error: 'Refresh failed' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
this.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
@@ -64,69 +109,58 @@ class ApiClient {
|
||||
|
||||
// 2. Check for 401 (Unauthorized)
|
||||
if (response.status === 401) {
|
||||
if (!this.isRefreshing) {
|
||||
this.isRefreshing = true
|
||||
|
||||
try {
|
||||
// Attempt to refresh token
|
||||
const refreshReq = await RefreshToken()
|
||||
|
||||
if (refreshReq.ok) {
|
||||
const data = await refreshReq.json()
|
||||
const newToken = data.token || data.access_token
|
||||
|
||||
// Update Local Storage
|
||||
localStorage.setItem('token', newToken)
|
||||
if (data.expire || data.access_token_expiry) {
|
||||
localStorage.setItem(
|
||||
'token_expiry',
|
||||
data.expire || data.access_token_expiry,
|
||||
)
|
||||
// Always queue this request first
|
||||
const queuedPromise = new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({
|
||||
resolve: async token => {
|
||||
if (!token) {
|
||||
reject(new Error('Token refresh failed'))
|
||||
return
|
||||
}
|
||||
|
||||
// Process queue with success
|
||||
this.processQueue(null, newToken)
|
||||
|
||||
// Retry the original request with new token
|
||||
const newHeaders = this.getHeaders(options?.headers)
|
||||
const retryConfig = {
|
||||
...config,
|
||||
headers: newHeaders,
|
||||
}
|
||||
|
||||
response = await fetch(url, retryConfig)
|
||||
|
||||
// If it fails again with 401, force logout
|
||||
if (response.status === 401) {
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// Refresh failed (e.g., refresh token expired)
|
||||
this.processQueue(new Error('Token refresh failed'), null)
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
} finally {
|
||||
this.isRefreshing = false
|
||||
}
|
||||
} else {
|
||||
// Token is currently being refreshed, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({
|
||||
resolve: (token) => {
|
||||
// Retry the original request with new token
|
||||
try {
|
||||
const newHeaders = this.getHeaders(options?.headers)
|
||||
const retryConfig = {
|
||||
...config,
|
||||
headers: newHeaders,
|
||||
}
|
||||
resolve(fetch(url, retryConfig))
|
||||
},
|
||||
reject
|
||||
})
|
||||
const retryResponse = await fetch(url, retryConfig)
|
||||
resolve(retryResponse)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
reject,
|
||||
})
|
||||
})
|
||||
|
||||
// If already refreshing, just return the queued promise
|
||||
if (this.isRefreshing) {
|
||||
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
|
||||
}
|
||||
|
||||
const refreshResult = await this.refreshToken()
|
||||
|
||||
if (refreshResult.success) {
|
||||
// Process queue with success - this will retry all queued requests
|
||||
this.processQueue(null, refreshResult.token)
|
||||
} else {
|
||||
// Refresh failed
|
||||
this.processQueue(new Error(refreshResult.error), null)
|
||||
this.handleLogout()
|
||||
return null
|
||||
}
|
||||
|
||||
// Return the queued promise for this request
|
||||
return queuedPromise
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -197,7 +197,7 @@ const MyChores = () => {
|
||||
if (
|
||||
!choresLoading &&
|
||||
!membersLoading &&
|
||||
userProfile &&
|
||||
userProfile?.id &&
|
||||
membersData?.res &&
|
||||
choresData?.res
|
||||
) {
|
||||
@@ -245,7 +245,7 @@ const MyChores = () => {
|
||||
isUserProfileLoading,
|
||||
choresData?.res,
|
||||
membersData?.res,
|
||||
userProfile?.id,
|
||||
// userProfile?.id, NOT HERE
|
||||
impersonatedUser?.userId,
|
||||
selectedChoreSection,
|
||||
])
|
||||
@@ -1506,6 +1506,7 @@ const MyChores = () => {
|
||||
|
||||
if (
|
||||
isUserProfileLoading ||
|
||||
userProfile === null ||
|
||||
userLabelsLoading ||
|
||||
membersLoading ||
|
||||
choresLoading
|
||||
@@ -1569,25 +1570,6 @@ const MyChores = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* {activeTextField != 'search' && (
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
}}
|
||||
onClick={() => {
|
||||
setActiveTextFieldWithCache('search')
|
||||
setSearchInputFocus(searchInputFocus + 1)
|
||||
|
||||
searchInputRef?.current?.focus()
|
||||
}}
|
||||
>
|
||||
<Search />
|
||||
</IconButton>
|
||||
)} */}
|
||||
<SortAndGrouping
|
||||
title='Group by'
|
||||
k={'icon-menu-group-by'}
|
||||
|
||||
Reference in New Issue
Block a user