Files
pulse/app/auth/callback/page.tsx
Usman Baig 6338d1dfe7 fix: prevent infinite reload loop on stale build recovery
Use sessionStorage guard so the hard reload only fires once. If the
reload doesn't fix it (CDN still serving stale JS), fall through
gracefully instead of looping forever.
2026-03-07 19:55:16 +01:00

135 lines
5.0 KiB
TypeScript

'use client'
import { useEffect, useState, Suspense, useRef, useCallback } from 'react'
import { logger } from '@/lib/utils/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/context'
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
import { exchangeAuthCode } from '@/app/actions/auth'
import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui'
import { LoadingOverlay } from '@ciphera-net/ui'
function AuthCallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { login } = useAuth()
const [error, setError] = useState<string | null>(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) return
let result: Awaited<ReturnType<typeof exchangeAuthCode>>
try {
result = await exchangeAuthCode(code, codeVerifier, redirectUri)
} catch {
// * Stale build — cached JS has old Server Action hashes. Hard reload once to fix.
const key = 'pulse_reload_for_stale_build'
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, '1')
window.location.reload()
return
}
sessionStorage.removeItem(key)
setError('Something went wrong. Please try logging in again.')
return
}
if (result.success && result.user) {
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
try {
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
login(merged)
} catch {
login(result.user)
}
localStorage.removeItem('oauth_state')
localStorage.removeItem('oauth_code_verifier')
if (localStorage.getItem('pulse_pending_checkout')) {
router.push('/welcome')
} else {
const raw = searchParams.get('returnTo') || '/'
const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
router.push(safe)
}
} else {
setError(authMessageFromErrorType(result.error as AuthErrorType))
}
}, [searchParams, login, router])
useEffect(() => {
if (processedRef.current && !isRetrying) return
const code = searchParams.get('code')
if (!code) return
const state = searchParams.get('state')
const storedState = localStorage.getItem('oauth_state')
const codeVerifier = localStorage.getItem('oauth_code_verifier')
// * Session flow (from auth hub): redirect has code but no state. Clear stale PKCE
// * data from any previous app-initiated OAuth so exchange proceeds without validation.
if (!state) {
localStorage.removeItem('oauth_state')
localStorage.removeItem('oauth_code_verifier')
} else {
// * Full OAuth flow (app-initiated): validate state + use PKCE
const isFullOAuth = !!storedState && !!codeVerifier
if (isFullOAuth && state !== storedState) {
logger.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state')
return
}
}
processedRef.current = true
if (isRetrying) setIsRetrying(false)
runCodeExchange()
}, [searchParams, login, router, isRetrying, runCodeExchange])
const handleRetry = () => {
setError(null)
processedRef.current = false
setIsRetrying(true)
}
if (error) {
const isNetworkError = error.includes('Network error')
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 text-red-500">
{error}
<div className="mt-4 flex flex-col gap-2">
{isNetworkError && (
<button type="button" onClick={handleRetry} className="text-sm underline text-left">
Try again
</button>
)}
<button
type="button"
onClick={() => { window.location.href = `${AUTH_URL}/login` }}
className="text-sm underline text-left"
>
Back to Login
</button>
</div>
</div>
</div>
)
}
// * Use standard Pulse loading screen to make transition to Home seamless
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
export default function AuthCallback() {
return (
<Suspense fallback={<LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />}>
<AuthCallbackContent />
</Suspense>
)
}