'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(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> try { result = await exchangeAuthCode(code, codeVerifier, redirectUri) } catch { // * Stale build or network error — show error so user can retry via full navigation 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 (
{error}
{isNetworkError && ( )}
) } // * Use standard Pulse loading screen to make transition to Home seamless return } export default function AuthCallback() { return ( }> ) }