From 5aeacef51140d93dc9158b452a57277f6deafa13 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 26 Dec 2025 19:59:39 -0500 Subject: [PATCH] fix: enhance SSE error handling and token refresh logic; improve user profile checks in MyChores component --- src/hooks/useSSE.js | 80 +++++++++++++++++-- src/utils/ApiClient.js | 144 +++++++++++++++++++++------------- src/views/Chores/MyChores.jsx | 24 +----- 3 files changed, 166 insertions(+), 82 deletions(-) diff --git a/src/hooks/useSSE.js b/src/hooks/useSSE.js index ecd7c22..cb381b9 100644 --- a/src/hooks/useSSE.js +++ b/src/hooks/useSSE.js @@ -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) } diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 51d6bbc..c192d86 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -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 diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index f341c65..ed83406 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -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' && ( - { - setActiveTextFieldWithCache('search') - setSearchInputFocus(searchInputFocus + 1) - - searchInputRef?.current?.focus() - }} - > - - - )} */}