'use client'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import type { Session, User } from '@supabase/supabase-js'; import { getBrowserSupabase } from '@/lib/supabase'; export type Tier = 'free' | 'analyst' | 'desk'; interface UserProfile { tier: Tier; scan_count: number; scan_reset_date: string; founder_pricing: boolean; subscription_status: 'none' | 'active' | 'grace_period' | 'expired' | 'canceled'; subscription_end: string | null; mfa_setup_prompted: boolean; } interface AuthContextValue { user: User | null; session: Session | null; profile: UserProfile | null; tier: Tier; scanCount: number; scansRemaining: number | null; canScan: boolean; loading: boolean; signUp: (email: string, password: string, ageVerified: boolean) => Promise<{ error?: string }>; signIn: (email: string, password: string) => Promise<{ error?: string }>; signInWithGoogle: () => Promise; // Session 13 — generalized OAuth dispatch. Apple/Twitter call paths // exist in the UI; whether the call SUCCEEDS depends on the // provider being configured in the Supabase dashboard. Unconfigured // providers return an error string the login page surfaces inline. signInWithProvider: (provider: 'google' | 'apple' | 'twitter') => Promise<{ error?: string }>; signOut: () => Promise; refresh: () => Promise; bumpScanCount: () => void; // Marks the MFA setup nag as seen so we don't ask the same user again. // Independent of whether they actually enabled MFA. markMFAPrompted: () => Promise; } const FREE_LIMIT = 5; // reads per calendar month const monthKey = () => new Date().toISOString().slice(0, 7) + '-01'; // YYYY-MM-01 const isSameMonth = (date: string | null | undefined) => !!date && date.slice(0, 7) === new Date().toISOString().slice(0, 7); const AuthContext = createContext(null); export default function AuthProvider({ children }: { children: React.ReactNode }) { const supabase = getBrowserSupabase(); const [user, setUser] = useState(null); const [session, setSession] = useState(null); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const loadProfile = useCallback( async (currentUser: User | null) => { if (!supabase || !currentUser) { setProfile(null); return; } const { data, error } = await supabase .from('user_profiles') .select('tier, scan_count, scan_reset_date, founder_pricing, subscription_status, subscription_end, mfa_setup_prompted') .eq('id', currentUser.id) .single(); if (error || !data) { setProfile({ tier: 'free', scan_count: 0, scan_reset_date: monthKey(), founder_pricing: false, subscription_status: 'none', subscription_end: null, mfa_setup_prompted: false, }); return; } const thisMonth = monthKey(); const needsReset = !isSameMonth(data.scan_reset_date); setProfile({ tier: (data.tier as Tier) || 'free', scan_count: needsReset ? 0 : data.scan_count || 0, scan_reset_date: thisMonth, founder_pricing: !!data.founder_pricing, subscription_status: data.subscription_status || 'none', subscription_end: data.subscription_end, mfa_setup_prompted: !!data.mfa_setup_prompted, }); }, [supabase], ); useEffect(() => { if (!supabase) { setLoading(false); return; } let mounted = true; supabase.auth.getSession().then(({ data }) => { if (!mounted) return; setSession(data.session); setUser(data.session?.user ?? null); loadProfile(data.session?.user ?? null).finally(() => { if (mounted) setLoading(false); }); }); const { data: sub } = supabase.auth.onAuthStateChange((_event, newSession) => { setSession(newSession); setUser(newSession?.user ?? null); void loadProfile(newSession?.user ?? null); }); return () => { mounted = false; sub.subscription.unsubscribe(); }; }, [supabase, loadProfile]); const refresh = useCallback(async () => { await loadProfile(user); }, [loadProfile, user]); const bumpScanCount = useCallback(() => { setProfile((p) => (p ? { ...p, scan_count: p.scan_count + 1 } : p)); }, []); const signUp = useCallback( async (email, password, ageVerified) => { if (!ageVerified) return { error: 'You must confirm you are 21 or older.' }; if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' }; const { error } = await supabase.auth.signUp({ email, password, options: { emailRedirectTo: `${window.location.origin}/auth/callback` }, }); if (error) return { error: error.message }; return {}; }, [supabase], ); const signIn = useCallback( async (email, password) => { if (!supabase) return { error: 'Auth is not configured. Set Supabase env vars.' }; const { error } = await supabase.auth.signInWithPassword({ email, password }); if (error) return { error: error.message }; return {}; }, [supabase], ); // Session 13 — generic OAuth dispatcher. Supabase returns an error // object when the provider isn't configured in the dashboard // (Apple needs a Service ID + private key; Twitter/X needs an // OAuth 2.0 client). We translate the upstream error into a flat // `{ error: string }` shape so the login UI can show a friendly // line without inspecting Supabase internals. const signInWithProvider = useCallback( async (provider) => { if (!supabase) return { error: 'Auth not initialized' }; try { const { error } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: `${window.location.origin}/auth/callback` }, }); if (error) { return { error: `${provider} login isn't available yet. Use email or another method.` }; } return {}; } catch { return { error: 'Login failed. Try another method.' }; } }, [supabase], ); // Kept as a thin alias so legacy callers (signup/login pages) keep // working without churn. New code should call signInWithProvider. const signInWithGoogle = useCallback(async () => { await signInWithProvider('google'); }, [signInWithProvider]); const signOut = useCallback(async () => { if (!supabase) return; await supabase.auth.signOut(); setProfile(null); }, [supabase]); const markMFAPrompted = useCallback(async () => { if (!supabase || !user) return; setProfile((p) => (p ? { ...p, mfa_setup_prompted: true } : p)); await supabase.from('user_profiles').update({ mfa_setup_prompted: true }).eq('id', user.id); }, [supabase, user]); const value = useMemo(() => { const tier = profile?.tier ?? 'free'; const scanCount = profile?.scan_count ?? 0; const scansRemaining = tier === 'free' ? Math.max(0, FREE_LIMIT - scanCount) : null; const canScan = tier !== 'free' || scansRemaining === null || (scansRemaining ?? 0) > 0; return { user, session, profile, tier, scanCount, scansRemaining, canScan, loading, signUp, signIn, signInWithGoogle, signInWithProvider, signOut, refresh, bumpScanCount, markMFAPrompted, }; }, [user, session, profile, loading, signUp, signIn, signInWithGoogle, signInWithProvider, signOut, refresh, bumpScanCount, markMFAPrompted]); return {children}; } export function useAuth(): AuthContextValue { const ctx = useContext(AuthContext); if (!ctx) { return { user: null, session: null, profile: null, tier: 'free', scanCount: 0, scansRemaining: 5, canScan: true, loading: false, signUp: async () => ({ error: 'Auth not initialized' }), signIn: async () => ({ error: 'Auth not initialized' }), signInWithGoogle: async () => {}, signInWithProvider: async () => ({ error: 'Auth not initialized' }), signOut: async () => {}, refresh: async () => {}, bumpScanCount: () => {}, markMFAPrompted: async () => {}, }; } return ctx; }