diff --git a/CHANGELOG.md b/CHANGELOG.md index 110965b..e8929e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. - **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. diff --git a/lib/api/client.ts b/lib/api/client.ts index 9b82f2b..6362b8c 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -244,21 +244,30 @@ async function apiRequest( // * If refresh is already in progress, wait for it to complete (or fail) return new Promise((resolve, reject) => { subscribeToTokenRefresh( - async () => { - try { - const retryResponse = await fetch(url, { - ...options, - headers, - credentials: 'include', - }) - if (retryResponse.ok) { - resolve(await retryResponse.json()) - } else { - reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status)) - } - } catch (e) { - reject(e) + () => { + // * Retry with fresh headers after refresh completes + const retryHeaders: Record = { + 'Content-Type': 'application/json', + [getRequestIdHeader()]: generateRequestId(), } + if (options.headers) { + Object.entries(options.headers as Record).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) ) @@ -279,22 +288,40 @@ async function apiRequest( // * Refresh successful, cookies updated onRefreshed() - // * Retry original request + // * Retry original request with fresh headers + const retryHeaders: Record = { + 'Content-Type': 'application/json', + [getRequestIdHeader()]: generateRequestId(), + } + if (options.headers) { + Object.entries(options.headers as Record).forEach(([key, value]) => { + retryHeaders[key] = value + }) + } + if (isStateChangingMethod(method)) { + const csrfToken = getCSRFToken() + if (csrfToken) retryHeaders['X-CSRF-Token'] = csrfToken + } const retryResponse = await fetch(url, { ...options, - headers, + headers: retryHeaders, credentials: 'include', }) - + if (retryResponse.ok) { 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 { const sessionExpiredMsg = authMessageFromStatus(401) onRefreshFailed(new ApiError(sessionExpiredMsg, 401)) localStorage.removeItem('user') + throw new ApiError(sessionExpiredMsg, 401) } } catch (e) { + if (e instanceof ApiError) throw e const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError') ? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0) : e diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index eeb8e90..ef5f7af 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' import { useRouter, usePathname } from 'next/navigation' +import { useSWRConfig } from 'swr' import apiRequest from '@/lib/api/client' import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui' 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 router = useRouter() const pathname = usePathname() + const { mutate: swrMutate } = useSWRConfig() const refreshToken = useCallback(async (): Promise => { try { @@ -109,7 +111,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const refresh = useCallback(async () => { try { const userData = await apiRequest('/auth/user/me') - + setUser(prev => { const merged = { ...userData, @@ -122,8 +124,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch (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]) + }, [router, swrMutate]) const refreshSession = useCallback(async () => { await refresh()