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 { useUserProfile } from '../queries/UserQueries'
|
||||||
import { useAlerts } from '../service/AlertsProvider'
|
import { useAlerts } from '../service/AlertsProvider'
|
||||||
import { useNotification } from '../service/NotificationProvider'
|
import { useNotification } from '../service/NotificationProvider'
|
||||||
import { apiClient } from '../utils/apiClient'
|
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,
|
||||||
@@ -38,7 +38,11 @@ export const useSSE = () => {
|
|||||||
const getSSEUrl = useCallback(() => {
|
const getSSEUrl = useCallback(() => {
|
||||||
const authToken = token
|
const authToken = token
|
||||||
if (!authToken || !isAuthenticated) {
|
if (!authToken || !isAuthenticated) {
|
||||||
console.log('SSE: No valid authentication token')
|
console.log(
|
||||||
|
'SSE: No valid authentication token',
|
||||||
|
authToken,
|
||||||
|
isAuthenticated,
|
||||||
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +71,27 @@ export const useSSE = () => {
|
|||||||
// Handle different event types and update React Query cache accordingly
|
// Handle different event types and update React Query cache accordingly
|
||||||
switch (eventData.type) {
|
switch (eventData.type) {
|
||||||
case 'chore.created':
|
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.updated':
|
||||||
case 'chore.completed':
|
case 'chore.completed':
|
||||||
case 'chore.status':
|
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.
|
// 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 ${sseConfig.token}`,
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Accept: 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
},
|
},
|
||||||
@@ -371,25 +396,68 @@ export const useSSE = () => {
|
|||||||
|
|
||||||
eventSourceRef.current.onmessage = handleSSEMessage
|
eventSourceRef.current.onmessage = handleSSEMessage
|
||||||
|
|
||||||
eventSourceRef.current.onerror = error => {
|
eventSourceRef.current.onerror = async error => {
|
||||||
console.error('SSE error:', error)
|
console.error('SSE error:', error)
|
||||||
setConnectionState(SSE_STATES.CLOSED)
|
setConnectionState(SSE_STATES.CLOSED)
|
||||||
stopHeartbeatMonitor()
|
stopHeartbeatMonitor()
|
||||||
|
|
||||||
if (!isManuallyClosedRef.current) {
|
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
|
// Check if this is a timeout error specifically
|
||||||
const isTimeoutError =
|
const isTimeoutError =
|
||||||
error.error?.message?.includes('No activity within') ||
|
error.error?.message?.includes('No activity within') ||
|
||||||
error.error?.message?.includes('timeout')
|
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...')
|
console.log('SSE timeout detected, attempting reconnection...')
|
||||||
setError('Connection timeout - reconnecting...')
|
setError('Connection timeout - reconnecting...')
|
||||||
} else {
|
} else {
|
||||||
setError('Connection error occurred')
|
setError('Connection error occurred')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule reconnect
|
// Schedule reconnect for non-401 errors
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
clearTimeout(reconnectTimeoutRef.current)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,51 @@ class ApiClient {
|
|||||||
this.baseURL = `${API_URL}/api/v1`
|
this.baseURL = `${API_URL}/api/v1`
|
||||||
this.isRefreshing = false
|
this.isRefreshing = false
|
||||||
this.failedQueue = []
|
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() {
|
getToken() {
|
||||||
@@ -64,69 +109,58 @@ class ApiClient {
|
|||||||
|
|
||||||
// 2. Check for 401 (Unauthorized)
|
// 2. Check for 401 (Unauthorized)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
if (!this.isRefreshing) {
|
// Always queue this request first
|
||||||
this.isRefreshing = true
|
const queuedPromise = new Promise((resolve, reject) => {
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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({
|
this.failedQueue.push({
|
||||||
resolve: (token) => {
|
resolve: async token => {
|
||||||
// Retry the original request with new token
|
if (!token) {
|
||||||
|
reject(new Error('Token refresh failed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
const newHeaders = this.getHeaders(options?.headers)
|
const newHeaders = this.getHeaders(options?.headers)
|
||||||
const retryConfig = {
|
const retryConfig = {
|
||||||
...config,
|
...config,
|
||||||
headers: newHeaders,
|
headers: newHeaders,
|
||||||
}
|
}
|
||||||
resolve(fetch(url, retryConfig))
|
const retryResponse = await fetch(url, retryConfig)
|
||||||
},
|
resolve(retryResponse)
|
||||||
reject
|
} 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
|
return response
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ const MyChores = () => {
|
|||||||
if (
|
if (
|
||||||
!choresLoading &&
|
!choresLoading &&
|
||||||
!membersLoading &&
|
!membersLoading &&
|
||||||
userProfile &&
|
userProfile?.id &&
|
||||||
membersData?.res &&
|
membersData?.res &&
|
||||||
choresData?.res
|
choresData?.res
|
||||||
) {
|
) {
|
||||||
@@ -245,7 +245,7 @@ const MyChores = () => {
|
|||||||
isUserProfileLoading,
|
isUserProfileLoading,
|
||||||
choresData?.res,
|
choresData?.res,
|
||||||
membersData?.res,
|
membersData?.res,
|
||||||
userProfile?.id,
|
// userProfile?.id, NOT HERE
|
||||||
impersonatedUser?.userId,
|
impersonatedUser?.userId,
|
||||||
selectedChoreSection,
|
selectedChoreSection,
|
||||||
])
|
])
|
||||||
@@ -1506,6 +1506,7 @@ const MyChores = () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
isUserProfileLoading ||
|
isUserProfileLoading ||
|
||||||
|
userProfile === null ||
|
||||||
userLabelsLoading ||
|
userLabelsLoading ||
|
||||||
membersLoading ||
|
membersLoading ||
|
||||||
choresLoading
|
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
|
<SortAndGrouping
|
||||||
title='Group by'
|
title='Group by'
|
||||||
k={'icon-menu-group-by'}
|
k={'icon-menu-group-by'}
|
||||||
|
|||||||
Reference in New Issue
Block a user