From eaf02c853fa076e7ca395cff276574239bc81044 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 3 Feb 2026 19:31:26 +0100 Subject: [PATCH] fix: improve error handling across various components; utilize getAuthErrorMessage for consistent user-facing error messages --- app/actions/auth.ts | 18 ++- app/auth/callback/page.tsx | 110 ++++++++++--------- app/onboarding/page.tsx | 5 +- app/page.tsx | 5 +- app/share/[id]/page.tsx | 3 +- app/sites/[id]/page.tsx | 5 +- app/sites/[id]/realtime/page.tsx | 9 +- app/sites/[id]/settings/page.tsx | 9 +- app/sites/new/page.tsx | 3 +- components/settings/OrganizationSettings.tsx | 11 +- lib/api/client.ts | 50 ++++++--- lib/utils/authErrors.ts | 63 +++++++++++ 12 files changed, 199 insertions(+), 92 deletions(-) create mode 100644 lib/utils/authErrors.ts diff --git a/app/actions/auth.ts b/app/actions/auth.ts index d2a935d..79d0e15 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -29,6 +29,9 @@ interface UserPayload { role?: string } +/** Error type returned to client for mapping to user-facing copy (no sensitive details). */ +export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server' + export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { try { const res = await fetch(`${AUTH_API_URL}/oauth/token`, { @@ -46,8 +49,10 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir }) if (!res.ok) { - const data = await res.json() - throw new Error(data.error || 'Failed to exchange token') + const status = res.status + const errorType: AuthExchangeErrorType = + status === 401 ? 'expired' : status === 403 ? 'invalid' : 'server' + return { success: false as const, error: errorType } } const data: AuthResponse = await res.json() @@ -96,9 +101,12 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir } } - } catch (error: any) { + } catch (error: unknown) { console.error('Auth Exchange Error:', error) - return { success: false, error: error.message } + const isNetwork = + error instanceof TypeError || + (error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message))) + return { success: false as const, error: isNetwork ? 'network' : 'server' } } } @@ -152,7 +160,7 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin } } catch (e) { console.error('[setSessionAction] Error:', e) - return { success: false, error: 'Invalid token' } + return { success: false as const, error: 'invalid' } } } diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index b67d4d1..db02ff4 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -1,10 +1,11 @@ 'use client' -import { useEffect, useState, Suspense, useRef } from 'react' +import { useEffect, useState, Suspense, useRef, useCallback } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useAuth } from '@/lib/auth/context' import { AUTH_URL } from '@/lib/api/client' import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' +import { authMessageFromErrorType, type AuthErrorType } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' function AuthCallbackContent() { @@ -12,39 +13,58 @@ function AuthCallbackContent() { const searchParams = useSearchParams() const { login } = useAuth() const [error, setError] = useState(null) + const [isRetrying, setIsRetrying] = useState(false) const processedRef = useRef(false) + const runCodeExchange = useCallback(async () => { + const code = searchParams.get('code') + const codeVerifier = localStorage.getItem('oauth_code_verifier') + const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : '' + if (!code || !codeVerifier) return + const result = await exchangeAuthCode(code, codeVerifier, redirectUri) + if (result.success && result.user) { + login(result.user) + localStorage.removeItem('oauth_state') + localStorage.removeItem('oauth_code_verifier') + if (localStorage.getItem('pulse_pending_checkout')) { + router.push('/pricing') + } else { + router.push('/') + } + } else { + setError(authMessageFromErrorType(result.error as AuthErrorType)) + } + }, [searchParams, login, router]) + useEffect(() => { // * Prevent double execution (React Strict Mode or fast re-renders) - if (processedRef.current) return - + if (processedRef.current && !isRetrying) return + // * Check for direct token passing (from auth-frontend direct login) // * This flow exposes tokens in URL, kept for legacy support. // * Recommended: Use Authorization Code flow (below) const token = searchParams.get('token') const refreshToken = searchParams.get('refresh_token') - + if (token && refreshToken) { - processedRef.current = true - - const handleDirectTokens = async () => { - const result = await setSessionAction(token, refreshToken) - if (result.success && result.user) { - login(result.user) - const returnTo = searchParams.get('returnTo') || '/' - router.push(returnTo) - } else { - setError('Invalid token received') - } + processedRef.current = true + const handleDirectTokens = async () => { + const result = await setSessionAction(token, refreshToken) + if (result.success && result.user) { + login(result.user) + const returnTo = searchParams.get('returnTo') || '/' + router.push(returnTo) + } else { + setError(authMessageFromErrorType('invalid')) } - handleDirectTokens() - return + } + handleDirectTokens() + return } const code = searchParams.get('code') const state = searchParams.get('state') - // * Skip if params are missing (might be initial render before params are ready) if (!code || !state) return const storedState = localStorage.getItem('oauth_state') @@ -61,47 +81,33 @@ function AuthCallbackContent() { } processedRef.current = true + if (isRetrying) setIsRetrying(false) + runCodeExchange() + }, [searchParams, login, router, isRetrying, runCodeExchange]) - const exchangeCode = async () => { - try { - const redirectUri = window.location.origin + '/auth/callback' - const result = await exchangeAuthCode(code, codeVerifier, redirectUri) - - if (!result.success || !result.user) { - throw new Error(result.error || 'Failed to exchange token') - } - - login(result.user) - - // * Cleanup - localStorage.removeItem('oauth_state') - localStorage.removeItem('oauth_code_verifier') - - // * Check for pending checkout - if (localStorage.getItem('pulse_pending_checkout')) { - router.push('/pricing') - } else { - router.push('/') - } - } catch (err: any) { - setError(err.message) - } - } - - exchangeCode() - }, [searchParams, login, router]) + const handleRetry = () => { + setError(null) + setIsRetrying(true) + } if (error) { + const isNetworkError = error.includes('Network error') return (
- Error: {error} -
- + )} +
diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index f8d8ecd..1be720f 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import { createOrganization } from '@/lib/api/organization' import { useAuth } from '@/lib/auth/context' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' import { Button, Input } from '@ciphera-net/ui' @@ -24,8 +25,8 @@ export default function OnboardingPage() { await createOrganization(name, slug) // * Redirect to home, AuthContext will detect the new org and auto-switch router.push('/') - } catch (err: any) { - setError(err.message || 'Failed to create organization') + } catch (err: unknown) { + setError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to create organization') } finally { setLoading(false) } diff --git a/app/page.tsx b/app/page.tsx index 7a5921f..33c8dab 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,6 +11,7 @@ import SiteList from '@/components/sites/SiteList' import { Button } from '@ciphera-net/ui' import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' function DashboardPreview() { return ( @@ -114,7 +115,7 @@ export default function HomePage() { const data = await listSites() setSites(Array.isArray(data) ? data : []) } catch (error: any) { - toast.error('Failed to load sites: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to load sites: ' + ((error as Error)?.message || 'Unknown error')) setSites([]) } finally { setSitesLoading(false) @@ -143,7 +144,7 @@ export default function HomePage() { toast.success('Site deleted successfully') loadSites() } catch (error: any) { - toast.error('Failed to delete site: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error')) } } diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 8be96eb..ff9839d 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { useParams, useSearchParams, useRouter } from 'next/navigation' import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' import Chart from '@/components/dashboard/Chart' import TopPages from '@/components/dashboard/ContentStats' @@ -171,7 +172,7 @@ export default function PublicDashboardPage() { } else if (error.status === 404 || error.response?.status === 404) { toast.error('Site not found') } else if (!silent) { - toast.error('Failed to load dashboard: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard: ' + ((error as Error)?.message || 'Unknown error')) } } finally { if (!silent) setLoading(false) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index e24e6ad..da85fa1 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -7,6 +7,7 @@ import { getSite, type Site } from '@/lib/api/sites' import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format' import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' import ExportModal from '@/components/dashboard/ExportModal' @@ -191,8 +192,8 @@ export default function SiteDashboardPage() { setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : []) setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 }) setPerformanceByPage(data.performance_by_page ?? null) - } catch (error: any) { - toast.error('Failed to load data: ' + (error.message || 'Unknown error')) + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error')) } finally { setLoading(false) } diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx index 7e5461e..342a183 100644 --- a/app/sites/[id]/realtime/page.tsx +++ b/app/sites/[id]/realtime/page.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation' import { getSite, type Site } from '@/lib/api/sites' import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' function formatTimeAgo(dateString: string) { @@ -44,8 +45,8 @@ export default function RealtimePage() { if (visitorsData && visitorsData.length > 0) { handleSelectVisitor(visitorsData[0]) } - } catch (error: any) { - toast.error('Failed to load data') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to load data') } finally { setLoading(false) } @@ -81,8 +82,8 @@ export default function RealtimePage() { try { const events = await getSessionDetails(siteId, visitor.session_id) setSessionEvents(events || []) - } catch (error) { - toast.error('Failed to load session details') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to load session details') } finally { setLoadingEvents(false) } diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index 9e20475..603bd57 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay } from '@ciphera-net/ui' import VerificationModal from '@/components/sites/VerificationModal' import { PasswordInput } from '@ciphera-net/ui' @@ -105,7 +106,7 @@ export default function SiteSettingsPage() { setIsPasswordEnabled(false) } } catch (error: any) { - toast.error('Failed to load site: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to load site: ' + ((error as Error)?.message || 'Unknown error')) } finally { setLoading(false) } @@ -142,7 +143,7 @@ export default function SiteSettingsPage() { toast.success('Site updated successfully') loadSite() } catch (error: any) { - toast.error('Failed to update site: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to update site: ' + ((error as Error)?.message || 'Unknown error')) } finally { setSaving(false) } @@ -157,7 +158,7 @@ export default function SiteSettingsPage() { await resetSiteData(siteId) toast.success('All site data has been reset') } catch (error: any) { - toast.error('Failed to reset data: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to reset data: ' + ((error as Error)?.message || 'Unknown error')) } } @@ -173,7 +174,7 @@ export default function SiteSettingsPage() { toast.success('Site deleted successfully') router.push('/') } catch (error: any) { - toast.error('Failed to delete site: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error')) } } diff --git a/app/sites/new/page.tsx b/app/sites/new/page.tsx index c40c312..ddf7adf 100644 --- a/app/sites/new/page.tsx +++ b/app/sites/new/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { createSite, listSites } from '@/lib/api/sites' import { getSubscription } from '@/lib/api/billing' import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { Button, Input } from '@ciphera-net/ui' export default function NewSitePage() { @@ -46,7 +47,7 @@ export default function NewSitePage() { toast.success('Site created successfully') router.push(`/sites/${site.id}`) } catch (error: any) { - toast.error('Failed to create site: ' + (error.message || 'Unknown error')) + toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error')) } finally { setLoading(false) } diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 7fcead0..4b90121 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -18,6 +18,7 @@ import { } from '@/lib/api/organization' import { getSubscription, createPortalSession, SubscriptionDetails } from '@/lib/api/billing' import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { motion, AnimatePresence } from 'framer-motion' import { AlertTriangleIcon, @@ -137,7 +138,7 @@ export default function OrganizationSettings() { const { url } = await createPortalSession() window.location.href = url } catch (error: any) { - toast.error(error.message || 'Failed to redirect to billing portal') + toast.error(getAuthErrorMessage(error) || error.message || 'Failed to redirect to billing portal') setIsRedirectingToPortal(false) } } @@ -166,7 +167,7 @@ export default function OrganizationSettings() { } catch (err: any) { console.error(err) - toast.error(err.message || 'Failed to delete organization') + toast.error(getAuthErrorMessage(err) || err.message || 'Failed to delete organization') setIsDeleting(false) } } @@ -195,7 +196,7 @@ export default function OrganizationSettings() { setCaptchaToken('') loadMembers() // Refresh list } catch (error: any) { - toast.error(error.message || 'Failed to send invitation') + toast.error(getAuthErrorMessage(error) || error.message || 'Failed to send invitation') } finally { setIsInviting(false) } @@ -207,7 +208,7 @@ export default function OrganizationSettings() { toast.success('Invitation revoked') loadMembers() // Refresh list } catch (error: any) { - toast.error(error.message || 'Failed to revoke invitation') + toast.error(getAuthErrorMessage(error) || error.message || 'Failed to revoke invitation') } } @@ -222,7 +223,7 @@ export default function OrganizationSettings() { setIsEditing(false) loadMembers() } catch (error: any) { - toast.error(error.message || 'Failed to update organization') + toast.error(getAuthErrorMessage(error) || error.message || 'Failed to update organization') } finally { setIsSaving(false) } diff --git a/lib/api/client.ts b/lib/api/client.ts index 30678c9..27068aa 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -2,6 +2,11 @@ * HTTP client wrapper for API calls */ +import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@/lib/utils/authErrors' + +/** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */ +const FETCH_TIMEOUT_MS = 30_000 + export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000' export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' @@ -73,11 +78,26 @@ async function apiRequest( // * We rely on HttpOnly cookies, so no manual Authorization header injection. // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site). - const response = await fetch(url, { - ...options, - headers, - credentials: 'include', // * IMPORTANT: Send cookies - }) + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + const signal = options.signal ?? controller.signal + + let response: Response + try { + response = await fetch(url, { + ...options, + headers, + credentials: 'include', // * IMPORTANT: Send cookies + signal, + }) + clearTimeout(timeoutId) + } catch (e) { + clearTimeout(timeoutId) + if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) { + throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0) + } + throw e + } if (!response.ok) { if (response.status === 401) { @@ -99,7 +119,7 @@ async function apiRequest( if (retryResponse.ok) { resolve(await retryResponse.json()) } else { - reject(new ApiError('Retry failed', retryResponse.status)) + reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status)) } } catch (e) { reject(e) @@ -135,12 +155,16 @@ async function apiRequest( return retryResponse.json() } } else { - onRefreshFailed(new ApiError('Refresh failed', 401)) + const sessionExpiredMsg = authMessageFromStatus(401) + onRefreshFailed(new ApiError(sessionExpiredMsg, 401)) localStorage.removeItem('user') } } catch (e) { - onRefreshFailed(e) - throw e + const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError') + ? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0) + : e + onRefreshFailed(err) + throw err } finally { isRefreshing = false } @@ -148,11 +172,9 @@ async function apiRequest( } } - const errorBody = await response.json().catch(() => ({ - error: 'Unknown error', - message: `HTTP ${response.status}: ${response.statusText}`, - })) - throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status, errorBody) + const errorBody = await response.json().catch(() => ({})) + const message = authMessageFromStatus(response.status) + throw new ApiError(message, response.status, errorBody) } return response.json() diff --git a/lib/utils/authErrors.ts b/lib/utils/authErrors.ts new file mode 100644 index 0000000..ef138c9 --- /dev/null +++ b/lib/utils/authErrors.ts @@ -0,0 +1,63 @@ +/** + * Auth error message mapping for user-facing copy. + * Maps status codes and error types to safe, actionable messages (no sensitive details). + */ + +export const AUTH_ERROR_MESSAGES = { + /** Shown when session/token is expired; prompts re-login. */ + SESSION_EXPIRED: 'Session expired, please sign in again.', + /** Shown when credentials are invalid (e.g. wrong password, invalid token). */ + INVALID_CREDENTIALS: 'Invalid credentials', + /** Shown on network failure or timeout; prompts retry. */ + NETWORK: 'Network error, please try again.', + /** Generic fallback for server/unknown errors. */ + GENERIC: 'Something went wrong, please try again.', +} as const + +/** + * Returns the user-facing message for a given HTTP status from an API/auth response. + * Used when building ApiError messages and when mapping server-returned error types. + */ +export function authMessageFromStatus(status: number): string { + if (status === 401) return AUTH_ERROR_MESSAGES.SESSION_EXPIRED + if (status === 403) return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS + if (status >= 500) return AUTH_ERROR_MESSAGES.GENERIC + return AUTH_ERROR_MESSAGES.GENERIC +} + +/** Error type returned by auth server actions for mapping to user-facing copy. */ +export type AuthErrorType = 'network' | 'expired' | 'invalid' | 'server' + +/** + * Maps server-action error type (e.g. from exchangeAuthCode) to user-facing message. + * Used in auth callback so no sensitive details are shown. + */ +export function authMessageFromErrorType(type: AuthErrorType): string { + switch (type) { + case 'expired': + return AUTH_ERROR_MESSAGES.SESSION_EXPIRED + case 'invalid': + return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS + case 'network': + return AUTH_ERROR_MESSAGES.NETWORK + case 'server': + default: + return AUTH_ERROR_MESSAGES.GENERIC + } +} + +/** + * Maps an error (e.g. ApiError, network/abort) to a safe user-facing message. + * Use this when displaying API/auth errors in the UI so expired, invalid, and network + * cases show the correct copy without exposing sensitive details. + */ +export function getAuthErrorMessage(error: unknown): string { + if (!error) return AUTH_ERROR_MESSAGES.GENERIC + const err = error as { status?: number; name?: string; message?: string } + if (typeof err.status === 'number') return authMessageFromStatus(err.status) + if (err.name === 'AbortError') return AUTH_ERROR_MESSAGES.NETWORK + if (err instanceof Error && (err.name === 'TypeError' || /fetch|network|failed to fetch/i.test(err.message || ''))) { + return AUTH_ERROR_MESSAGES.NETWORK + } + return AUTH_ERROR_MESSAGES.GENERIC +}