import { createContext, useContext, useEffect, useState, ReactNode, useCallback, } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { springAuth } from "@app/auth/springAuthClient"; import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup"; import type { Session, User, AuthError, AuthChangeEvent, } from "@app/auth/springAuthClient"; /** * Auth Context Type * Simplified version without SaaS-specific features (credits, subscriptions) */ interface AuthContextType { session: Session | null; user: User | null; /** * Human-readable name to show in the UI for the current session. * - A real identity (username/email) when the user is signed in. * - The localised "User" placeholder for anonymous sessions * (proprietary's chosen label - see deriveDisplayName). * - null only when there is no user object at all (signed-out), so * consumers can fall back to whatever makes sense. */ displayName: string | null; loading: boolean; error: AuthError | null; signOut: () => Promise; refreshSession: () => Promise; } /** * Derive a display name from the Spring user. Anonymous users get the * localised "User" placeholder (proprietary's chosen label for unsigned-in * sessions); returns null only when there is no user object at all so * consumers can pick their own fallback. * * Exported for unit testing. */ export function deriveDisplayName( user: User | null | undefined, t: TFunction, ): string | null { if (!user) return null; if (user.is_anonymous) return t("auth.displayName.user", "User"); return user.username || user.email || null; } const AuthContext = createContext({ session: null, user: null, displayName: null, loading: true, error: null, signOut: async () => {}, refreshSession: async () => {}, }); /** * Auth Provider Component * * Manages authentication state and provides it to the entire app. * Integrates with Spring Security + JWT backend. */ export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Debug: Track state transitions useEffect(() => { console.log("[Auth] State changed:", { loading, hasSession: !!session, hasError: !!error, userId: session?.user?.id, timestamp: new Date().toISOString(), }); }, [loading, session, error]); /** * Refresh current session */ const refreshSession = useCallback(async () => { try { setLoading(true); setError(null); console.debug("[Auth] refreshSession: start", { path: window.location.pathname, }); console.debug("[Auth] Refreshing session..."); const { data, error } = await springAuth.refreshSession(); if (error) { console.error("[Auth] Session refresh error:", error); setError(error); setSession(null); } else { console.debug("[Auth] Session refreshed successfully"); setSession(data.session); } } catch (err) { console.error("[Auth] Unexpected error during session refresh:", err); setError(err as AuthError); } finally { console.debug("[Auth] refreshSession: done", { hasSession: !!session }); setLoading(false); } }, []); /** * Sign out user */ const signOut = useCallback(async () => { try { setError(null); console.debug("[Auth] Signing out..."); const { error } = await springAuth.signOut(); // Always clear the in-memory session: springAuth.signOut() removes the // local token and platform user_info even when the backend POST fails, // so the user is effectively signed out either way. Leaving session // populated on error would mean the UI keeps the old user's badge until // a manual reload (the SIGNED_OUT notifyListeners call also covers this // path now, but clearing here is defence in depth). setSession(null); if (error) { console.error("[Auth] Sign out error:", error); setError(error); } else { console.debug("[Auth] Signed out successfully"); } } catch (err) { console.error("[Auth] Unexpected error during sign out:", err); setSession(null); setError(err as AuthError); } }, []); /** * Initialize auth on mount */ useEffect(() => { let mounted = true; const mountId = Math.random().toString(36).substring(7); console.log(`[Auth:${mountId}] 🔵 AuthProvider mounted`); const initializeAuth = async () => { try { console.debug(`[Auth:${mountId}] Initializing auth...`); console.debug( `[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`, ); // Clear any platform-specific cached auth on login page init. if ( typeof window !== "undefined" && window.location.pathname.startsWith("/login") ) { await clearPlatformAuthOnLoginInit(); } // Skip config check entirely - let the app handle login state // The config will be fetched by useAppConfig when needed const { data, error } = await springAuth.getSession(); if (!mounted) return; if (error) { console.error("[Auth] Initial session error:", error); setError(error); } else { console.debug("[Auth] Initial session loaded:", { hasSession: !!data.session, userId: data.session?.user?.id, email: data.session?.user?.email, }); setSession(data.session); } } catch (err) { console.error( "[Auth] Unexpected error during auth initialization:", err, ); if (mounted) { setError(err as AuthError); } } finally { console.debug( `[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`, ); if (mounted) { setLoading(false); } } }; initializeAuth(); // Listen for jwt-available event (triggered by desktop auth or other sources) const handleJwtAvailable = () => { console.log(`[Auth:${mountId}] ════════════════════════════════════`); console.log(`[Auth:${mountId}] 🔄 JWT available event received`); console.log( `[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`, ); console.log( `[Auth:${mountId}] Setting loading=true to stabilize auth state`, ); setLoading(true); // Prevent unstable renders during auth state transition setError(null); console.log(`[Auth:${mountId}] Refreshing session...`); void initializeAuth(); }; window.addEventListener("jwt-available", handleJwtAvailable); // Subscribe to auth state changes const { data: { subscription }, } = springAuth.onAuthStateChange( async (event: AuthChangeEvent, newSession: Session | null) => { if (!mounted) { console.log( `[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`, ); return; } console.log(`[Auth:${mountId}] ════════════════════════════════════`); console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`); console.log(`[Auth:${mountId}] Has session: ${!!newSession}`); console.log( `[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`, ); console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`); // Schedule state update setTimeout(() => { if (mounted) { console.log( `[Auth:${mountId}] Applying session update (event: ${event})`, ); setSession(newSession); setError(null); // Handle specific events if (event === "SIGNED_OUT") { console.log( `[Auth:${mountId}] ✓ User signed out, session cleared`, ); } else if (event === "SIGNED_IN") { console.log(`[Auth:${mountId}] ✓ User signed in successfully`); } else if (event === "TOKEN_REFRESHED") { console.log(`[Auth:${mountId}] ✓ Token refreshed`); } else if (event === "USER_UPDATED") { console.log(`[Auth:${mountId}] ✓ User updated`); } } else { console.log( `[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`, ); } }, 0); }, ); return () => { console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`); mounted = false; window.removeEventListener("jwt-available", handleJwtAvailable); subscription.unsubscribe(); }; }, []); const { t } = useTranslation(); const user = session?.user ?? null; const value: AuthContextType = { session, user, displayName: deriveDisplayName(user, t), loading, error, signOut, refreshSession, }; return {children}; } /** * Hook to access auth context * Must be used within AuthProvider */ export function useAuth() { const context = useContext(AuthContext); if (context === undefined) { throw new Error("useAuth must be used within an AuthProvider"); } return context; } /** * Debug hook to expose auth state for debugging * Can be used in development to monitor auth state */ export function useAuthDebug() { const auth = useAuth(); useEffect(() => { console.debug("[Auth Debug] Current auth state:", { hasSession: !!auth.session, hasUser: !!auth.user, loading: auth.loading, hasError: !!auth.error, userId: auth.user?.id, email: auth.user?.email, }); }, [auth.session, auth.user, auth.loading, auth.error]); return auth; }