fix: resolve intermittent auth errors when navigating between tabs
Token refresh race condition: when multiple requests got 401 simultaneously, queued retries reused stale headers and the initiator fell through without throwing on retry failure. Now retries regenerate headers (fresh request ID and CSRF token), and both retry failure and refresh failure throw explicitly. SWR cache is now invalidated after token refresh so stale error responses are not served from cache.
This commit is contained in:
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **No more random errors when switching tabs.** Navigating between Dashboard, Funnels, Uptime, and Settings no longer shows "Invalid credentials", "Something went wrong", or "Site not found" errors. This was caused by a timing issue when your login session refreshed in the background while multiple pages were loading at the same time — all those requests now wait for the refresh to finish and retry cleanly.
|
||||||
- **More accurate pageview counts.** Refreshing a page no longer inflates your pageview numbers. The tracking script now detects when the same page is loaded again within a few seconds and skips the duplicate, so metrics like total pageviews, pages per session, and visit duration reflect real navigation instead of reload habits.
|
- **More accurate pageview counts.** Refreshing a page no longer inflates your pageview numbers. The tracking script now detects when the same page is loaded again within a few seconds and skips the duplicate, so metrics like total pageviews, pages per session, and visit duration reflect real navigation instead of reload habits.
|
||||||
- **Self-referrals no longer pollute your traffic sources.** Internal navigation within your own site (e.g. clicking from your homepage to your about page) no longer shows your own domain as a referrer. Only external traffic sources appear in your Referrers panel now.
|
- **Self-referrals no longer pollute your traffic sources.** Internal navigation within your own site (e.g. clicking from your homepage to your about page) no longer shows your own domain as a referrer. Only external traffic sources appear in your Referrers panel now.
|
||||||
- **Screen size fallback now works correctly.** A variable naming issue prevented the fallback screen dimensions from being read when the primary value wasn't available. Screen size data is now reliably captured on all browsers.
|
- **Screen size fallback now works correctly.** A variable naming issue prevented the fallback screen dimensions from being read when the primary value wasn't available. Screen size data is now reliably captured on all browsers.
|
||||||
|
|||||||
@@ -244,21 +244,30 @@ async function apiRequest<T>(
|
|||||||
// * If refresh is already in progress, wait for it to complete (or fail)
|
// * If refresh is already in progress, wait for it to complete (or fail)
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
subscribeToTokenRefresh(
|
subscribeToTokenRefresh(
|
||||||
async () => {
|
() => {
|
||||||
try {
|
// * Retry with fresh headers after refresh completes
|
||||||
const retryResponse = await fetch(url, {
|
const retryHeaders: Record<string, string> = {
|
||||||
...options,
|
'Content-Type': 'application/json',
|
||||||
headers,
|
[getRequestIdHeader()]: generateRequestId(),
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
if (retryResponse.ok) {
|
|
||||||
resolve(await retryResponse.json())
|
|
||||||
} else {
|
|
||||||
reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reject(e)
|
|
||||||
}
|
}
|
||||||
|
if (options.headers) {
|
||||||
|
Object.entries(options.headers as Record<string, string>).forEach(([key, value]) => {
|
||||||
|
retryHeaders[key] = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isStateChangingMethod(method)) {
|
||||||
|
const csrfToken = getCSRFToken()
|
||||||
|
if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken
|
||||||
|
}
|
||||||
|
fetch(url, { ...options, headers: retryHeaders, credentials: 'include' })
|
||||||
|
.then(async (retryResponse) => {
|
||||||
|
if (retryResponse.ok) {
|
||||||
|
resolve(await retryResponse.json())
|
||||||
|
} else {
|
||||||
|
reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => reject(e))
|
||||||
},
|
},
|
||||||
(err) => reject(err)
|
(err) => reject(err)
|
||||||
)
|
)
|
||||||
@@ -279,22 +288,40 @@ async function apiRequest<T>(
|
|||||||
// * Refresh successful, cookies updated
|
// * Refresh successful, cookies updated
|
||||||
onRefreshed()
|
onRefreshed()
|
||||||
|
|
||||||
// * Retry original request
|
// * Retry original request with fresh headers
|
||||||
|
const retryHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
[getRequestIdHeader()]: generateRequestId(),
|
||||||
|
}
|
||||||
|
if (options.headers) {
|
||||||
|
Object.entries(options.headers as Record<string, string>).forEach(([key, value]) => {
|
||||||
|
retryHeaders[key] = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isStateChangingMethod(method)) {
|
||||||
|
const csrfToken = getCSRFToken()
|
||||||
|
if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken
|
||||||
|
}
|
||||||
const retryResponse = await fetch(url, {
|
const retryResponse = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers: retryHeaders,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (retryResponse.ok) {
|
if (retryResponse.ok) {
|
||||||
return retryResponse.json()
|
return retryResponse.json()
|
||||||
}
|
}
|
||||||
|
// * Retry failed — throw with the retry response status, not the original 401
|
||||||
|
const retryBody = await retryResponse.json().catch(() => ({}))
|
||||||
|
throw new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status, retryBody)
|
||||||
} else {
|
} else {
|
||||||
const sessionExpiredMsg = authMessageFromStatus(401)
|
const sessionExpiredMsg = authMessageFromStatus(401)
|
||||||
onRefreshFailed(new ApiError(sessionExpiredMsg, 401))
|
onRefreshFailed(new ApiError(sessionExpiredMsg, 401))
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
|
throw new ApiError(sessionExpiredMsg, 401)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) throw e
|
||||||
const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')
|
const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')
|
||||||
? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
||||||
: e
|
: e
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
|
import { useSWRConfig } from 'swr'
|
||||||
import apiRequest from '@/lib/api/client'
|
import apiRequest from '@/lib/api/client'
|
||||||
import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui'
|
import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui'
|
||||||
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
|
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
|
||||||
@@ -51,6 +52,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const { mutate: swrMutate } = useSWRConfig()
|
||||||
|
|
||||||
const refreshToken = useCallback(async (): Promise<boolean> => {
|
const refreshToken = useCallback(async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
@@ -109,7 +111,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const userData = await apiRequest<User>('/auth/user/me')
|
const userData = await apiRequest<User>('/auth/user/me')
|
||||||
|
|
||||||
setUser(prev => {
|
setUser(prev => {
|
||||||
const merged = {
|
const merged = {
|
||||||
...userData,
|
...userData,
|
||||||
@@ -122,8 +124,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to refresh user data', e)
|
logger.error('Failed to refresh user data', e)
|
||||||
}
|
}
|
||||||
|
// * Clear SWR cache so stale data isn't served after token refresh
|
||||||
|
swrMutate(() => true, undefined, { revalidate: true })
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}, [router])
|
}, [router, swrMutate])
|
||||||
|
|
||||||
const refreshSession = useCallback(async () => {
|
const refreshSession = useCallback(async () => {
|
||||||
await refresh()
|
await refresh()
|
||||||
|
|||||||
Reference in New Issue
Block a user