import React, { createContext, useContext, useEffect, useState, useMemo, useCallback, } from "react"; import { User, Session } from "@supabase/supabase-js"; import { supabase } from "@/lib/supabase"; import { UserProfile } from "@/lib/database.types"; import { aethexToast } from "@/lib/aethex-toast"; import { aethexUserService, aethexRoleService, type AethexUserProfile, checkProfileComplete, } from "@/lib/aethex-database-adapter"; type SupportedOAuthProvider = "github" | "google"; interface LinkedProvider { provider: SupportedOAuthProvider; identityId?: string; linkedAt?: string; lastSignInAt?: string; } interface AuthContextType { user: User | null; profile: AethexUserProfile | null; roles: string[]; session: Session | null; loading: boolean; profileComplete: boolean; linkedProviders: LinkedProvider[]; signIn: (email: string, password: string) => Promise; signUp: ( email: string, password: string, userData?: Partial, ) => Promise; signInWithOAuth: (provider: SupportedOAuthProvider) => Promise; linkProvider: (provider: SupportedOAuthProvider) => Promise; unlinkProvider: (provider: SupportedOAuthProvider) => Promise; signOut: () => Promise; updateProfile: (updates: Partial) => Promise; refreshProfile: () => Promise; } const AuthContext = createContext(undefined); let warnedMissingProvider = false; const missingProviderFallback: AuthContextType = { user: null, profile: null, roles: [], session: null, loading: true, profileComplete: false, linkedProviders: [], signIn: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, signUp: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, signInWithOAuth: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, linkProvider: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, unlinkProvider: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, signOut: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, updateProfile: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, refreshProfile: async () => { throw new Error( "AuthProvider is not mounted. Please ensure your app is wrapped with .", ); }, }; const SIGN_OUT_TIMEOUT_MS = 4000; function withTimeout( promise: Promise, timeoutMs: number, timeoutMessage = "Operation timed out", ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(timeoutMessage)); }, timeoutMs); promise .then((value) => { clearTimeout(timer); resolve(value); }) .catch((error) => { clearTimeout(timer); reject(error); }); }); } export const useAuth = () => { const context = useContext(AuthContext); if (context === undefined) { if (!warnedMissingProvider) { console.warn( "useAuth called without an AuthProvider. Falling back to safe defaults.", ); warnedMissingProvider = true; } return missingProviderFallback; } return context; }; export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const [user, setUser] = useState(null); const [profile, setProfile] = useState(null); const [roles, setRoles] = useState([]); const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { // Add timeout to ensure loading doesn't get stuck const loadingTimeout = setTimeout(() => { console.log("Auth loading timeout - forcing loading to false"); setLoading(false); }, 5000); // Get initial session supabase.auth .getSession() .then(({ data: { session } }) => { clearTimeout(loadingTimeout); setSession(session); setUser(session?.user ?? null); if (session?.user) { fetchUserProfile(session.user.id); } else { setLoading(false); } }) .catch((error) => { clearTimeout(loadingTimeout); console.error("Error getting session:", error); setLoading(false); }); // Listen for auth changes const { data: { subscription }, } = supabase.auth.onAuthStateChange(async (event, session) => { setSession(session); setUser(session?.user ?? null); if (session?.user) { await fetchUserProfile(session.user.id); } else { setProfile(null); setRoles([]); } setLoading(false); // Handle token refresh failures specifically if (event === "TOKEN_REFRESH_FAILED") { console.warn("Token refresh failed - clearing local session"); try { clearClientAuthState(); } catch (e) { /* ignore */ } try { aethexToast.error({ title: "Session expired", description: "Your session could not be refreshed and has been cleared. Please sign in again.", }); } catch (e) { /* ignore */ } return; } // Show toast notifications for auth events if (event === "SIGNED_IN") { aethexToast.success({ title: "Welcome back!", description: "Successfully signed in to AeThex OS", }); } else if (event === "SIGNED_OUT") { aethexToast.info({ title: "Signed out", description: "Come back soon!", }); } }); return () => subscription.unsubscribe(); }, []); const fetchUserProfile = async ( userId: string, ): Promise => { try { const userProfile = await aethexUserService.getCurrentUser(); setProfile(userProfile); try { let r = await aethexRoleService.getUserRoles(userId); // Auto-seed owner roles if logging in as site owner const ownerEmail = userProfile?.email?.toLowerCase(); if (ownerEmail === "mrpiglr@gmail.com" && !r.includes("owner")) { const seeded = Array.from( new Set(["owner", "admin", "founder", ...r]), ); await aethexRoleService.setUserRoles(userId, seeded); r = seeded; } setRoles(r); } catch { setRoles([]); } setLoading(false); return userProfile; } catch (error) { console.error("Error fetching user profile:", error); setLoading(false); return null; } }; const refreshAuthState = useCallback(async () => { try { const { data } = await supabase.auth.getSession(); setSession(data.session); setUser(data.session?.user ?? null); } catch (error: any) { console.warn("Failed to refresh auth state:", error); const msg = String(error?.message ?? error).toLowerCase(); if ( msg.includes("invalid refresh token") || msg.includes("session expired") || msg.includes("revoked") ) { try { clearClientAuthState(); } catch (e) { /* ignore */ } try { aethexToast.error({ title: "Session expired", description: "Your session has expired or was revoked. Please sign in again.", }); } catch (e) { /* ignore */ } } } }, []); const signIn = async (email: string, password: string) => { setLoading(true); try { const { error } = await supabase.auth.signInWithPassword({ email, password, }); if (error) throw error; // Wait for auth state change to update context try { await supabase.auth.getSession(); } catch {} } catch (error: any) { console.error("SignIn error details:", error); let errorMessage = error.message; if ( error.message?.includes("Failed to fetch") || error.name === "AuthRetryableFetchError" ) { errorMessage = "Unable to connect to authentication service. Please check your internet connection and try again."; } aethexToast.error({ title: "Sign in failed", description: errorMessage, }); setLoading(false); throw new Error(errorMessage); } }; const signUp = async ( email: string, password: string, userData?: Partial, ) => { setLoading(true); try { const { data, error } = await supabase.auth.signUp({ email, password, }); if (error) throw error; if (data.user) { aethexToast.success({ title: "Account created!", description: "Please check your email to verify your account, then sign in.", }); } } catch (error: any) { aethexToast.error({ title: "Sign up failed", description: error.message, }); throw error; } finally { setLoading(false); } }; const signInWithOAuth = async (provider: SupportedOAuthProvider) => { try { const { data, error } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/login`, }, }); if (error) throw error; aethexToast.success({ title: "Redirecting...", description: `Signing in with ${provider}`, }); } catch (error: any) { aethexToast.error({ title: `${provider} sign in failed`, description: error.message, }); throw error; } }; const linkProvider = useCallback( async (provider: SupportedOAuthProvider) => { if (!user) { aethexToast.error({ title: "Link failed", description: "You need to be signed in before linking providers.", }); return; } const alreadyLinked = user.identities?.some( (identity: any) => identity.provider === provider, ); if (alreadyLinked) { aethexToast.info({ title: "Already linked", description: `Your ${provider} account is already connected.`, }); return; } try { const { data, error } = (await supabase.auth.linkIdentity({ provider, redirectTo: `${window.location.origin}/dashboard?tab=connections`, })) as any; if (error) throw error; const linkUrl = data?.url; if (linkUrl) { window.location.href = linkUrl; return; } await refreshAuthState(); aethexToast.success({ title: "Account linked", description: `Your ${provider} account is now connected.`, }); } catch (error: any) { console.error("linkProvider error:", error); aethexToast.error({ title: "Link failed", description: error?.message || "Unable to link this provider right now.", }); } }, [user, refreshAuthState], ); const unlinkProvider = useCallback( async (provider: SupportedOAuthProvider) => { if (!user) { aethexToast.error({ title: "Unlink failed", description: "You need to be signed in to manage linked accounts.", }); return; } const identity = user.identities?.find( (item: any) => item.provider === provider, ); if (!identity) { aethexToast.info({ title: "Not linked", description: `No ${provider} account is linked to this profile.`, }); return; } const identities = (user.identities ?? []) as any[]; const supportedLinkedCount = identities.filter((item: any) => ["github", "google"].includes(item.provider), ).length; const hasEmailIdentity = identities.some( (item: any) => item.provider === "email", ); if (!hasEmailIdentity && supportedLinkedCount <= 1) { aethexToast.error({ title: "Cannot unlink provider", description: "Add another sign-in method before removing this connection.", }); return; } try { const { error } = (await supabase.auth.unlinkIdentity({ identity_id: identity.identity_id, provider, })) as any; if (error) throw error; await refreshAuthState(); aethexToast.success({ title: "Account unlinked", description: `Your ${provider} connection has been removed.`, }); } catch (error: any) { console.error("unlinkProvider error:", error); aethexToast.error({ title: "Unlink failed", description: error?.message || "Unable to unlink this provider right now.", }); } }, [user, refreshAuthState], ); const clearClientAuthState = useCallback(() => { setUser(null); setProfile(null); setRoles([]); setSession(null); if (typeof window !== "undefined") { try { window.localStorage.removeItem("onboarding_complete"); Object.keys(window.localStorage) .filter((key) => key.startsWith("sb-") || key.includes("supabase")) .forEach((key) => { window.localStorage.removeItem(key); }); } catch {} } }, []); // Global handler to catch auth refresh failures (e.g. Invalid Refresh Token) useEffect(() => { if (typeof window === "undefined") return; const onAuthError = (ev: any) => { const reason = ev?.reason || ev?.error || ev?.message || ev; const message = String( reason?.message ?? reason ?? ev?.toString?.() ?? "", ).toLowerCase(); if ( message.includes("invalid refresh token") || message.includes("session expired") || message.includes("revoked") ) { console.warn("Captured auth error (clearing local session):", reason); try { clearClientAuthState(); } catch (e) { /* ignore */ } try { aethexToast.error({ title: "Session expired", description: "Your session has expired or was revoked. Please sign in again.", }); } catch (e) { /* ignore */ } } }; window.addEventListener("unhandledrejection", onAuthError as any); window.addEventListener("error", onAuthError as any); return () => { window.removeEventListener("unhandledrejection", onAuthError as any); window.removeEventListener("error", onAuthError as any); }; }, [clearClientAuthState]); const signOut = async () => { setLoading(true); const issues: string[] = []; try { const { error: localError } = await supabase.auth.signOut({ scope: "local", }); if (localError?.message && !/session/i.test(localError.message)) { issues.push(localError.message); } } catch (error: any) { const message = error?.message ?? "Unable to clear local session."; if (!/session/i.test(message)) { issues.push(message); } } finally { clearClientAuthState(); setLoading(false); } try { const { error: globalError } = await withTimeout( supabase.auth.signOut({ scope: "global" }), SIGN_OUT_TIMEOUT_MS, "Supabase sign out timed out", ); if (globalError) { const status = (globalError as any)?.status; if (status !== 401) { issues.push( globalError.message ?? "Unable to reach authentication service.", ); } } } catch (error: any) { const message = error?.message ?? "Unable to reach authentication service."; issues.push(message); console.warn("Supabase global sign-out issue:", error); } const uniqueIssues = Array.from(new Set(issues)).filter(Boolean); if (uniqueIssues.length) { const hasTimeout = uniqueIssues.some((msg) => msg.toLowerCase().includes("timed out"), ); aethexToast.error({ title: "Sign out issue", description: hasTimeout ? "We couldn't reach Supabase to finish signing out, but your local session was cleared." : uniqueIssues[0], }); } else { aethexToast.info({ title: "Signed out", description: "You have been signed out successfully.", }); } }; const updateProfile = async (updates: Partial) => { if (!user) throw new Error("No user logged in"); try { const updatedProfile = await aethexUserService.updateProfile( user.id, updates, ); setProfile( (prev) => ({ ...(prev || ({} as any)), ...(updatedProfile || ({} as any)), ...updates, }) as any, ); aethexToast.success({ title: "Profile updated", description: "Your profile has been updated successfully", }); } catch (error: any) { setProfile((prev) => ({ ...(prev || ({} as any)), ...updates }) as any); aethexToast.error({ title: "Update failed", description: error.message, }); throw error; } }; const refreshProfile = async () => { if (user?.id) await fetchUserProfile(user.id); }; const linkedProviders = useMemo(() => { const supported: SupportedOAuthProvider[] = ["github", "google"]; if (!user?.identities) return []; return (user.identities as any[]) .filter((identity) => supported.includes(identity.provider as SupportedOAuthProvider), ) .map((identity) => ({ provider: identity.provider as SupportedOAuthProvider, identityId: identity.identity_id, linkedAt: identity.created_at, lastSignInAt: identity.last_sign_in_at, })); }, [user]); const profileCompletedByData = useMemo( () => checkProfileComplete(profile), [profile], ); const localOnboardingComplete = typeof window !== "undefined" && window.localStorage.getItem("onboarding_complete") === "1"; useEffect(() => { if (typeof window === "undefined") return; if (profileCompletedByData) { window.localStorage.setItem("onboarding_complete", "1"); } else if (window.localStorage.getItem("onboarding_complete") === "1") { window.localStorage.removeItem("onboarding_complete"); } }, [profileCompletedByData]); const computedComplete = profileCompletedByData || localOnboardingComplete; const value = { user, profile, roles, session, loading, profileComplete: computedComplete, linkedProviders, signIn, signUp, signInWithOAuth, linkProvider, unlinkProvider, signOut, updateProfile, refreshProfile, }; return {children}; };