| import { | |
| createContext, | |
| useCallback, | |
| useContext, | |
| useEffect, | |
| useMemo, | |
| useRef, | |
| useState, | |
| type ReactNode, | |
| } from "react"; | |
| import type { Session, User } from "@supabase/supabase-js"; | |
| import { fetchMe, type AccountInfo } from "./api"; | |
| import { | |
| getCachedAuthConfig, | |
| getSupabase, | |
| initSupabase, | |
| type PlanCard, | |
| } from "./supabase"; | |
| const ACTIVITY_EVENTS = [ | |
| "mousedown", | |
| "mousemove", | |
| "keydown", | |
| "scroll", | |
| "touchstart", | |
| "click", | |
| "visibilitychange", | |
| ] as const; | |
| type AuthContextValue = { | |
| ready: boolean; | |
| authEnabled: boolean; | |
| session: Session | null; | |
| user: User | null; | |
| account: AccountInfo | null; | |
| plans: PlanCard[]; | |
| guestMaxWords: number; | |
| sessionIdleMinutes: number; | |
| idleSignedOut: boolean; | |
| clearIdleNotice: () => void; | |
| refreshAccount: () => Promise<void>; | |
| setAccount: (account: AccountInfo | null) => void; | |
| signInWithPassword: (email: string, password: string) => Promise<void>; | |
| signUp: (email: string, password: string) => Promise<string>; | |
| signInWithGoogle: () => Promise<void>; | |
| signOut: () => Promise<void>; | |
| }; | |
| const AuthContext = createContext<AuthContextValue | null>(null); | |
| function useIdleSessionLogout( | |
| enabled: boolean, | |
| idleMinutes: number, | |
| hasSession: boolean, | |
| onIdle: () => void, | |
| ) { | |
| const timerRef = useRef<number | null>(null); | |
| const onIdleRef = useRef(onIdle); | |
| onIdleRef.current = onIdle; | |
| useEffect(() => { | |
| if (!enabled || !hasSession || idleMinutes <= 0) { | |
| if (timerRef.current != null) { | |
| window.clearTimeout(timerRef.current); | |
| timerRef.current = null; | |
| } | |
| return; | |
| } | |
| const ms = idleMinutes * 60 * 1000; | |
| const bump = () => { | |
| if (document.visibilityState === "hidden") return; | |
| if (timerRef.current != null) window.clearTimeout(timerRef.current); | |
| timerRef.current = window.setTimeout(() => { | |
| onIdleRef.current(); | |
| }, ms); | |
| }; | |
| bump(); | |
| for (const ev of ACTIVITY_EVENTS) { | |
| window.addEventListener(ev, bump, { passive: true }); | |
| } | |
| return () => { | |
| if (timerRef.current != null) window.clearTimeout(timerRef.current); | |
| for (const ev of ACTIVITY_EVENTS) { | |
| window.removeEventListener(ev, bump); | |
| } | |
| }; | |
| }, [enabled, hasSession, idleMinutes]); | |
| } | |
| export function AuthProvider({ children }: { children: ReactNode }) { | |
| const [ready, setReady] = useState(false); | |
| const [authEnabled, setAuthEnabled] = useState(false); | |
| const [session, setSession] = useState<Session | null>(null); | |
| const [account, setAccount] = useState<AccountInfo | null>(null); | |
| const [plans, setPlans] = useState<PlanCard[]>([]); | |
| const [guestMaxWords, setGuestMaxWords] = useState(100); | |
| const [sessionIdleMinutes, setSessionIdleMinutes] = useState(30); | |
| const [idleSignedOut, setIdleSignedOut] = useState(false); | |
| const refreshAccount = useCallback(async () => { | |
| if (!authEnabled) { | |
| setAccount(null); | |
| return; | |
| } | |
| try { | |
| const me = await fetchMe(session?.access_token); | |
| setAccount(me.account); | |
| } catch { | |
| setAccount(null); | |
| } | |
| }, [authEnabled, session]); | |
| const signOut = useCallback(async () => { | |
| const sb = getSupabase(); | |
| if (!sb) return; | |
| await sb.auth.signOut(); | |
| setAccount(null); | |
| }, []); | |
| const handleIdleLogout = useCallback(async () => { | |
| setIdleSignedOut(true); | |
| await signOut(); | |
| }, [signOut]); | |
| useIdleSessionLogout( | |
| authEnabled, | |
| sessionIdleMinutes, | |
| Boolean(session), | |
| () => { | |
| void handleIdleLogout(); | |
| }, | |
| ); | |
| useEffect(() => { | |
| let unsub: (() => void) | undefined; | |
| void (async () => { | |
| const config = await initSupabase(); | |
| setAuthEnabled(config.enabled); | |
| setPlans(config.plans ?? []); | |
| setGuestMaxWords(config.guest?.max_words_per_request ?? 100); | |
| setSessionIdleMinutes(config.session_idle_minutes ?? 30); | |
| const sb = getSupabase(); | |
| if (!config.enabled || !sb) { | |
| setReady(true); | |
| return; | |
| } | |
| const { data } = await sb.auth.getSession(); | |
| setSession(data.session); | |
| const { data: listener } = sb.auth.onAuthStateChange((_event, next) => { | |
| setSession(next); | |
| if (next) setIdleSignedOut(false); | |
| }); | |
| unsub = () => listener.subscription.unsubscribe(); | |
| setReady(true); | |
| })(); | |
| return () => unsub?.(); | |
| }, []); | |
| useEffect(() => { | |
| void refreshAccount(); | |
| }, [refreshAccount]); | |
| const value = useMemo<AuthContextValue>( | |
| () => ({ | |
| ready, | |
| authEnabled, | |
| session, | |
| user: session?.user ?? null, | |
| account, | |
| plans: plans.length ? plans : getCachedAuthConfig()?.plans ?? [], | |
| guestMaxWords, | |
| sessionIdleMinutes, | |
| idleSignedOut, | |
| clearIdleNotice: () => setIdleSignedOut(false), | |
| refreshAccount, | |
| setAccount, | |
| async signInWithPassword(email, password) { | |
| const sb = getSupabase(); | |
| if (!sb) throw new Error("Auth is not configured."); | |
| const { error } = await sb.auth.signInWithPassword({ email, password }); | |
| if (error) throw error; | |
| setIdleSignedOut(false); | |
| }, | |
| async signUp(email, password) { | |
| const sb = getSupabase(); | |
| if (!sb) throw new Error("Auth is not configured."); | |
| const { data, error } = await sb.auth.signUp({ email, password }); | |
| if (error) throw error; | |
| setIdleSignedOut(false); | |
| if (data.session) return "signed_in"; | |
| return "check_email"; | |
| }, | |
| async signInWithGoogle() { | |
| const sb = getSupabase(); | |
| if (!sb) throw new Error("Auth is not configured."); | |
| const { error } = await sb.auth.signInWithOAuth({ | |
| provider: "google", | |
| options: { redirectTo: window.location.origin }, | |
| }); | |
| if (error) throw error; | |
| }, | |
| async signOut() { | |
| setIdleSignedOut(false); | |
| await signOut(); | |
| await refreshAccount(); | |
| }, | |
| }), | |
| [ | |
| ready, | |
| authEnabled, | |
| session, | |
| account, | |
| plans, | |
| guestMaxWords, | |
| sessionIdleMinutes, | |
| idleSignedOut, | |
| refreshAccount, | |
| signOut, | |
| ], | |
| ); | |
| return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; | |
| } | |
| export function useAuth(): AuthContextValue { | |
| const ctx = useContext(AuthContext); | |
| if (!ctx) throw new Error("useAuth must be used within AuthProvider"); | |
| return ctx; | |
| } | |