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()
- }}
- >
-
-
- )} */}