Last active
September 5, 2025 16:28
-
-
Save andymagill/082d7389d9a03d16a234f0607adf0506 to your computer and use it in GitHub Desktop.
AuthContext Provider for Supabase in Next.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Authentication context and provider | |
| * | |
| * This module provides authentication state management using React Context. | |
| * It integrates with Supabase Auth using SSR-compatible client and provides | |
| * graceful fallback when Supabase is not available (offline mode). | |
| * | |
| * Features auth state caching to reduce loading flashes during navigation. | |
| */ | |
| 'use client'; | |
| import React, { | |
| createContext, | |
| useContext, | |
| useEffect, | |
| useState, | |
| useRef, | |
| useCallback, | |
| useMemo, | |
| } from 'react'; | |
| import { createSupabaseClient, withSupabaseClientErrorHandling } from '../lib/supabase-client'; | |
| import { User as SupabaseUser } from '@supabase/supabase-js'; | |
| import { User } from '../types'; | |
| import { logger } from '../lib/logger'; | |
| type Unsubscribable = { unsubscribe?: () => void } | (() => void) | { remove?: () => void } | null; | |
| /** | |
| * Authentication context interface | |
| */ | |
| interface AuthContextType { | |
| user: User | null | undefined; | |
| loading: boolean; | |
| isSupabaseAvailable: boolean; | |
| signUp: (email: string, password: string, name?: string) => Promise<void>; | |
| signIn: (email: string, password: string) => Promise<void>; | |
| signOut: () => Promise<void>; | |
| resetPassword: (email: string) => Promise<void>; | |
| updatePassword: (newPassword: string) => Promise<void>; | |
| resendConfirmation: (email: string) => Promise<void>; | |
| } | |
| const AuthContext = createContext<AuthContextType | undefined>(undefined); | |
| /** | |
| * Custom hook to access authentication context | |
| * | |
| * @returns AuthContextType - Authentication state and methods | |
| */ | |
| export function useAuth() { | |
| const context = useContext(AuthContext); | |
| logger.debug('[AuthContext] useAuth: CALLED', 'AuthContext', { | |
| metadata: { user: context?.user }, | |
| }); | |
| if (context === undefined) { | |
| throw new Error('useAuth must be used within an AuthProvider'); | |
| } | |
| // Don't warn for a null (unauthenticated) user — that's a normal state. | |
| if (context?.user === undefined) { | |
| logger.debug('[AuthContext] useAuth: USER_UNDEFINED_LOADING', 'AuthContext'); | |
| } | |
| return context; | |
| } | |
| /** | |
| * Authentication provider component | |
| * | |
| * @description Provides authentication state and methods to child components. | |
| * Handles Supabase session management and real-time authentication state updates. | |
| * Features auth state caching with sessionStorage to reduce loading flashes. | |
| * | |
| * @param props - Component props | |
| * @param props.children - React children to wrap with authentication context | |
| * | |
| * @example | |
| * ```tsx | |
| * <AuthProvider> | |
| * <App /> | |
| * </AuthProvider> | |
| * ``` | |
| */ | |
| export function AuthProvider({ children }: { children: React.ReactNode }) { | |
| // Use undefined for loading, null for unauthenticated, User for authenticated | |
| const [user, setUser] = useState<User | null | undefined>(undefined); | |
| const [loading, setLoading] = useState(true); // Start with true to show proper loading state | |
| useEffect(() => { | |
| logger.debug('[AuthContext] user state changed', 'AuthContext', { metadata: { user } }); | |
| if (user === undefined) { | |
| logger.debug('[AuthContext] user is undefined (loading)', 'AuthContext'); | |
| } else if (user === null) { | |
| logger.warn('[AuthContext] user is null (unauthenticated)', 'AuthContext'); | |
| } else { | |
| logger.debug('[AuthContext] user is set (authenticated)', 'AuthContext', { | |
| metadata: { user }, | |
| }); | |
| } | |
| }, [user]); | |
| // Create supabase client instance | |
| // Ensure a stable supabase client instance and avoid recreating it on every render | |
| const supabaseRef = useRef(createSupabaseClient()); | |
| const supabase = supabaseRef.current; | |
| const isSupabaseAvailable = !!supabase; | |
| const hasInitialized = useRef(false); | |
| const bgCheckIntervalRef = useRef<number | null>(null); | |
| const authSubscriptionRef = useRef<Unsubscribable | null>(null); | |
| const userRef = useRef<User | null | undefined>(user); | |
| const CACHE_KEY = 'mm_last_user'; | |
| // Update auth state | |
| const updateAuthState = useCallback((newUser: User | null | undefined): void => { | |
| // Unguarded console logs for production debugging: track auth state transitions | |
| try { | |
| console.log('[AuthContext] updateAuthState called', { newUser }); | |
| } catch { | |
| /* ignore */ | |
| } | |
| setUser(newUser); | |
| userRef.current = newUser; | |
| setLoading(false); | |
| }, []); | |
| /** | |
| * Maps Supabase user to application user format | |
| */ | |
| const mapSupabaseUser = useCallback( | |
| async (supabaseUser: SupabaseUser): Promise<User> => { | |
| if (!supabase) { | |
| throw new Error('Supabase client not available'); | |
| } | |
| // Fetch profile data to get admin flag | |
| const { data: profile, error } = await supabase | |
| .from('profiles') | |
| .select('admin') | |
| .eq('id', supabaseUser.id) | |
| .single(); | |
| if (error) { | |
| // Log and surface DB errors instead of silently falling back to non-admin | |
| logger.warn('[AuthContext] mapSupabaseUser: failed to fetch profile', 'AuthContext', { | |
| error: error instanceof Error ? error.message : String(error), | |
| userId: supabaseUser.id, | |
| }); | |
| throw error; | |
| } | |
| return { | |
| id: supabaseUser.id, | |
| email: supabaseUser.email!, | |
| name: supabaseUser.user_metadata?.name, | |
| avatar_url: supabaseUser.user_metadata?.avatar_url, | |
| admin: profile?.admin || false, | |
| }; | |
| }, | |
| [supabase], | |
| ); | |
| useEffect(() => { | |
| if (!supabase) { | |
| setUser(null); // Explicit unauthenticated if Supabase is not available | |
| setLoading(false); | |
| return; | |
| } | |
| if (hasInitialized.current) return; | |
| hasInitialized.current = true; | |
| // Helper: try mapping a Supabase user with a small retry loop to handle transient DB failures | |
| const tryMapUserWithRetries = async (supabaseUser: SupabaseUser, attempts = 2) => { | |
| let lastError: unknown = null; | |
| for (let i = 0; i <= attempts; i++) { | |
| try { | |
| const mapped = await mapSupabaseUser(supabaseUser); | |
| // Cache minimal last-known user id to reduce UI flicker on fast navigations | |
| try { | |
| if (typeof window !== 'undefined' && window.sessionStorage) { | |
| window.sessionStorage.setItem( | |
| CACHE_KEY, | |
| JSON.stringify({ id: mapped.id, ts: Date.now() }), | |
| ); | |
| } | |
| } catch (e) { | |
| /* ignore storage errors */ | |
| try { | |
| console.log( | |
| '[AuthContext] sessionStorage access failed', | |
| e instanceof Error ? e.message : String(e), | |
| ); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| return mapped; | |
| } catch (err) { | |
| lastError = err; | |
| try { | |
| console.warn('[AuthContext] tryMapUserWithRetries: mapping failed, retrying', { | |
| metadata: { attempt: i, error: err instanceof Error ? err.message : String(err) }, | |
| }); | |
| } catch { | |
| /* ignore */ | |
| } | |
| // small backoff | |
| await new Promise((res) => setTimeout(res, 250 * (i + 1))); | |
| } | |
| } | |
| throw lastError; | |
| }; | |
| // Load initial session with a retry strategy and clear, conservative fallbacks. | |
| const loadInitialSession = async () => { | |
| try { | |
| console.log('[AuthContext] loadInitialSession: calling supabase.auth.getSession'); | |
| console.log( | |
| '[AuthContext] loadInitialSession: supabase.auth methods ->', | |
| Object.keys(supabase.auth || {}), | |
| ); | |
| // Some versions of the Supabase JS auth layer perform async initialization | |
| // (exposed as `initializePromise`). If we call `getSession` before that | |
| // resolves it can hang indefinitely in certain environments (blocked | |
| // storage, browser privacy settings, or race conditions). Await the | |
| // internal initializePromise (short wait) where available before calling | |
| // getSession so we avoid spurious timeouts. | |
| try { | |
| // Typing the minimal known shape of the auth object init promise to avoid `any`. | |
| const authWithInit = supabase.auth as { initializePromise?: Promise<unknown> } | undefined; | |
| const init = authWithInit?.initializePromise; | |
| if (init && typeof (init as Promise<unknown>).then === 'function') { | |
| // Wait briefly (but not forever) for the internal init to settle. | |
| await Promise.race([init, new Promise((res) => setTimeout(res, 1500))]); | |
| } | |
| } catch (e) { | |
| // Non-fatal: proceed to getSession; the following logic will catch timeouts. | |
| console.warn('[AuthContext] loadInitialSession: initializePromise wait failed', String(e)); | |
| } | |
| // Wrap getSession in a timed race so it can't hang auth initialization. | |
| // Increase timeout slightly to avoid transient network/storage delays. | |
| const getSessionTimeoutMs = 12000; | |
| let dataResult: unknown; | |
| try { | |
| if (typeof supabase.auth.getSession !== 'function') { | |
| // Defensive: older or wrapped SDK shapes may not expose getSession as | |
| // an own property. Fall back to calling it if available on prototype | |
| // or use a safe access to avoid throwing here. | |
| console.warn('[AuthContext] loadInitialSession: supabase.auth.getSession is not a function; attempting safe call'); | |
| } | |
| const getSessionPromise = typeof supabase.auth.getSession === 'function' | |
| ? (supabase.auth.getSession as () => Promise<unknown>)() | |
| : Promise.resolve(undefined); | |
| dataResult = await Promise.race([ | |
| getSessionPromise, | |
| new Promise((_, reject) => | |
| window.setTimeout(() => reject(new Error('getSession_timeout')), getSessionTimeoutMs), | |
| ), | |
| ]); | |
| } catch (err) { | |
| console.error('[AuthContext] loadInitialSession: getSession timed out or errored', err); | |
| // Rethrow so outer retry logic can run (do not clear auth state immediately) | |
| throw err; | |
| } | |
| type MaybeSessionPayload = { session?: { user?: SupabaseUser | null } } | undefined; | |
| const sessionData = | |
| dataResult && | |
| typeof dataResult === 'object' && | |
| 'data' in (dataResult as Record<string, unknown>) | |
| ? ((dataResult as Record<string, unknown>)['data'] as MaybeSessionPayload) | |
| : undefined; | |
| console.log('[AuthContext] loadInitialSession: getSession result', { | |
| session: sessionData?.session, | |
| }); | |
| const session = sessionData?.session; | |
| if (session?.user) { | |
| const mappedUser = await tryMapUserWithRetries(session.user); | |
| updateAuthState(mappedUser); | |
| } else { | |
| // No session found. Clear any cached last-user if stale. | |
| try { | |
| if (typeof window !== 'undefined' && window.sessionStorage) { | |
| const raw = window.sessionStorage.getItem(CACHE_KEY); | |
| if (raw) { | |
| const parsed = JSON.parse(raw) as { id?: string; ts?: number }; | |
| // If cached entry is older than 60s, drop it to avoid stale assumptions | |
| if (parsed?.ts && Date.now() - parsed.ts > 60000) { | |
| window.sessionStorage.removeItem(CACHE_KEY); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| // ignore storage errors | |
| logger.debug('[AuthContext] sessionStorage access failed', 'AuthContext', { | |
| error: e instanceof Error ? e.message : String(e), | |
| }); | |
| } | |
| updateAuthState(null); | |
| } | |
| } catch (error) { | |
| try { | |
| console.error('[AuthContext] loadInitialSession: error calling getSession', error); | |
| } catch { | |
| /* ignore */ | |
| } | |
| try { | |
| console.error( | |
| 'Error getting initial session', | |
| error instanceof Error ? error.message : String(error), | |
| ); | |
| } catch { | |
| /* ignore */ | |
| } | |
| // Try one retry after short delay before failing open to null | |
| try { | |
| await new Promise((res) => setTimeout(res, 500)); | |
| const { data } = await supabase.auth.getSession(); | |
| try { | |
| console.log('[AuthContext] loadInitialSession: retry getSession result', { | |
| session: data?.session, | |
| }); | |
| } catch { | |
| /* ignore */ | |
| } | |
| const session = data?.session; | |
| if (session?.user) { | |
| const mappedUser = await tryMapUserWithRetries(session.user); | |
| updateAuthState(mappedUser); | |
| return; | |
| } | |
| } catch (retryError) { | |
| try { | |
| console.warn( | |
| 'Retry to get initial session failed', | |
| retryError instanceof Error ? retryError.message : String(retryError), | |
| ); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| updateAuthState(null); | |
| } | |
| }; | |
| // Listen for auth changes early so we don't miss SDK events emitted during | |
| // internal initialization (PKCE/code-exchange). Attach the listener | |
| // before calling loadInitialSession. | |
| if (!authSubscriptionRef.current) { | |
| const listenerReturn = supabase.auth.onAuthStateChange( | |
| async (event: string, session: unknown) => { | |
| try { | |
| // Special-case password recovery event: the SDK may emit this | |
| // when the client consumed a recovery link. Don't treat this as | |
| // an immediate unauthenticated state; let the reset-password | |
| // page handle the recovery session and user mapping. | |
| if (event === 'PASSWORD_RECOVERY') { | |
| try { | |
| console.log('[AuthContext] onAuthStateChange: PASSWORD_RECOVERY received'); | |
| } catch { | |
| /* ignore */ | |
| } | |
| // Ensure loading is cleared so UI doesn't show indefinite spinner | |
| setLoading(false); | |
| return; | |
| } | |
| // narrow session type | |
| const s = session as { user?: SupabaseUser | null } | null; | |
| if (s?.user) { | |
| const mappedUser = await tryMapUserWithRetries(s.user); | |
| updateAuthState(mappedUser); | |
| } else { | |
| updateAuthState(null); | |
| } | |
| } catch (error) { | |
| try { | |
| console.error( | |
| 'Error mapping user from auth state change', | |
| error instanceof Error ? error.message : String(error), | |
| ); | |
| } catch { | |
| /* ignore */ | |
| } | |
| updateAuthState(null); | |
| } | |
| setLoading(false); | |
| }, | |
| ); | |
| console.log('[AuthContext] attached onAuthStateChange listener (provider)'); | |
| // Unwrap known listener return shapes. The Supabase client historically | |
| // returned { data: { subscription } } while newer versions return the | |
| // subscription/unsubscribe directly. Normalize to the subscription object | |
| // so cleanup code can call unsubscribe/remove consistently. | |
| let normalized: Unsubscribable = listenerReturn as Unsubscribable; | |
| try { | |
| if (listenerReturn && typeof listenerReturn === 'object') { | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const anyLR = listenerReturn as any; | |
| if (anyLR.data && anyLR.data.subscription) { | |
| normalized = anyLR.data.subscription as Unsubscribable; | |
| } | |
| } | |
| } catch { | |
| /* ignore unwrap errors */ | |
| } | |
| authSubscriptionRef.current = normalized; | |
| } | |
| // Execute initial load | |
| loadInitialSession().catch((err) => { | |
| try { | |
| console.error('Unhandled error in loadInitialSession', String(err)); | |
| } catch { | |
| /* ignore */ | |
| } | |
| updateAuthState(null); | |
| }); | |
| // Periodic background session validation to catch silent session expirations or refreshes | |
| try { | |
| if (typeof window !== 'undefined') { | |
| bgCheckIntervalRef.current = window.setInterval( | |
| async () => { | |
| try { | |
| const { data } = await supabase.auth.getSession(); | |
| const session = data?.session; | |
| if (session?.user) { | |
| // If user changed, refresh mapping using ref to avoid re-subscribing effects | |
| const currentUser = userRef.current; | |
| if (!currentUser || currentUser.id !== session.user.id) { | |
| const mapped = await tryMapUserWithRetries(session.user); | |
| updateAuthState(mapped); | |
| } | |
| } else if (userRef.current !== null) { | |
| // No session but we thought user was logged-in -> set unauthenticated | |
| try { | |
| console.info('[AuthContext] background check: session missing, clearing user'); | |
| } catch { | |
| /* ignore */ | |
| } | |
| updateAuthState(null); | |
| } | |
| } catch (err) { | |
| try { | |
| console.log( | |
| '[AuthContext] background session check failed', | |
| err instanceof Error ? err.message : String(err), | |
| ); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| }, | |
| 1000 * 60 * 4, | |
| ); // every 4 minutes | |
| } | |
| } catch (err) { | |
| try { | |
| console.warn('[AuthContext] Failed to start background session check', String(err)); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| return () => { | |
| try { | |
| const sub = authSubscriptionRef.current as unknown; | |
| if (!sub) { | |
| // nothing to do | |
| } else if (typeof sub === 'function') { | |
| // unsubscribe function | |
| (sub as () => void)(); | |
| } else if (typeof sub === 'object') { | |
| interface SubWithUnsub { | |
| unsubscribe?: () => void; | |
| remove?: () => void; | |
| } | |
| const subObj = sub as SubWithUnsub; | |
| if (typeof subObj.unsubscribe === 'function') { | |
| subObj.unsubscribe(); | |
| } else if (typeof subObj.remove === 'function') { | |
| subObj.remove(); | |
| } else { | |
| try { | |
| console.log('[AuthContext] unknown subscription shape, cannot unsubscribe'); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| } else { | |
| logger.debug( | |
| '[AuthContext] unknown subscription type, cannot unsubscribe', | |
| 'AuthContext', | |
| ); | |
| } | |
| } catch (e) { | |
| try { | |
| console.log('[AuthContext] error unsubscribing', String(e)); | |
| } catch { | |
| /* ignore */ | |
| } | |
| } | |
| if (bgCheckIntervalRef.current) { | |
| clearInterval(bgCheckIntervalRef.current as number); | |
| bgCheckIntervalRef.current = null; | |
| } | |
| }; | |
| }, [updateAuthState, supabase, mapSupabaseUser]); | |
| const signUp = useCallback( | |
| async (email: string, password: string, name?: string) => { | |
| if (!supabase) throw new Error('Authentication is not available in offline mode.'); | |
| try { | |
| const data = await withSupabaseClientErrorHandling( | |
| 'signUp', | |
| async () => { | |
| const { data, error } = await supabase!.auth.signUp({ | |
| email, | |
| password, | |
| options: { | |
| data: { | |
| name, | |
| metadata: { hasName: !!name }, | |
| }, | |
| }, | |
| }); | |
| if (error) throw error; | |
| return data; | |
| }, | |
| { email }, | |
| ); | |
| // If signUp returned a user/session, map and update auth state | |
| if (data?.user) { | |
| try { | |
| const mapped = await mapSupabaseUser(data.user); | |
| updateAuthState(mapped); | |
| } catch (err) { | |
| logger.debug('[AuthContext] signUp: failed to map new user', 'AuthContext', { | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| } | |
| } | |
| } catch (error) { | |
| logger.error('Unexpected error during registration', 'AuthContext', { | |
| operation: 'signUp', | |
| error: { | |
| message: error instanceof Error ? error.message : String(error), | |
| name: error instanceof Error ? error.name : 'UnknownError', | |
| }, | |
| metadata: { email }, | |
| }); | |
| throw error; | |
| } | |
| }, | |
| [supabase, mapSupabaseUser, updateAuthState], | |
| ); | |
| const signIn = useCallback( | |
| async (email: string, password: string) => { | |
| if (!supabase) throw new Error('Authentication is not available in offline mode.'); | |
| setLoading(true); | |
| try { | |
| const data = await withSupabaseClientErrorHandling( | |
| 'signIn', | |
| async () => { | |
| const { data, error } = await supabase!.auth.signInWithPassword({ | |
| email, | |
| password, | |
| }); | |
| if (error) throw error; | |
| return data; | |
| }, | |
| { email }, | |
| ); | |
| // Eagerly set user from returned session | |
| if (data?.session?.user) { | |
| const mappedUser = await mapSupabaseUser(data.session.user); | |
| updateAuthState(mappedUser); | |
| } else { | |
| updateAuthState(null); | |
| } | |
| } catch (error) { | |
| setLoading(false); | |
| throw error; | |
| } | |
| }, | |
| [supabase, mapSupabaseUser, updateAuthState], | |
| ); | |
| const signOut = useCallback(async () => { | |
| if (!supabase) throw new Error('Authentication is not available in offline mode.'); | |
| await withSupabaseClientErrorHandling( | |
| 'signOut', | |
| async () => { | |
| const { error } = await supabase!.auth.signOut(); | |
| if (error) throw error; | |
| return true; | |
| }, | |
| {}, | |
| ); | |
| // Don't perform navigation at provider level; let callers handle post-logout routing. | |
| return; | |
| }, [supabase]); | |
| const resetPassword = useCallback( | |
| async (email: string) => { | |
| if (!supabase) throw new Error('Authentication is not available in offline mode.'); | |
| await withSupabaseClientErrorHandling( | |
| 'resetPassword', | |
| async () => { | |
| const { error } = await supabase!.auth.resetPasswordForEmail(email, { | |
| redirectTo: `${window.location.origin}/reset-password`, | |
| }); | |
| if (error) throw error; | |
| return true; | |
| }, | |
| { email }, | |
| ); | |
| }, | |
| [supabase], | |
| ); | |
| const updatePassword = useCallback( | |
| async (newPassword: string) => { | |
| if (!supabase) throw new Error('Authentication is not available in offline mode.'); | |
| // Log entry and current session for debugging password update flows | |
| try { | |
| console.log('[AuthContext] updatePassword called'); | |
| try { | |
| const { data: sessData } = await supabase.auth.getSession(); | |
| console.log('[AuthContext] updatePassword: current session', { | |
| session: sessData?.session, | |
| }); | |
| } catch (e) { | |
| console.log('[AuthContext] updatePassword: getSession failed', String(e)); | |
| } | |
| } catch { | |
| /* ignore logging failures */ | |
| } | |
| // If getSession returned no user, wait briefly for an onAuthStateChange | |
| // event so we don't call updateUser without the recovery session. | |
| try { | |
| const { data: sessData } = await supabase.auth.getSession(); | |
| if (!sessData?.session?.user) { | |
| console.log('[AuthContext] updatePassword: no session, waiting for onAuthStateChange (3s)'); | |
| await new Promise((resolve) => { | |
| let settled = false; | |
| const { unsubscribe } = supabase.auth.onAuthStateChange((event: string, session?: { user?: unknown } | null) => { | |
| try { | |
| if (session && (session as { user?: unknown }).user) { | |
| if (settled) return; | |
| settled = true; | |
| try { if (typeof unsubscribe === 'function') unsubscribe(); } catch { /* ignore */ } | |
| resolve(undefined); | |
| } | |
| } catch { | |
| /* ignore */ | |
| } | |
| }); | |
| setTimeout(() => { | |
| if (settled) return; | |
| settled = true; | |
| try { if (typeof unsubscribe === 'function') unsubscribe(); } catch { /* ignore */ } | |
| resolve(undefined); | |
| }, 3000); | |
| }); | |
| } | |
| } catch (e) { | |
| console.log('[AuthContext] updatePassword: error while waiting for session', String(e)); | |
| } | |
| await withSupabaseClientErrorHandling( | |
| 'updatePassword', | |
| async () => { | |
| // Try updateUser with a small retry loop to recover from transient | |
| // timing issues where the SDK session may be visible to events but | |
| // still not usable for updateUser. | |
| let lastError: unknown = null; | |
| for (let attempt = 1; attempt <= 2; attempt++) { | |
| try { | |
| const result = await supabase!.auth.updateUser({ password: newPassword }); | |
| try { | |
| console.log('[AuthContext] updatePassword: attempt result', { attempt, result }); | |
| } catch { | |
| /* ignore */ | |
| } | |
| // Supabase returns { data, error } shape; treat any error as throwable | |
| const resObj = result as { error?: unknown } | undefined; | |
| if (resObj?.error) { | |
| lastError = resObj.error; | |
| // If this was the first attempt, wait briefly and retry | |
| if (attempt === 1) { | |
| await new Promise((r) => setTimeout(r, 200)); | |
| continue; | |
| } | |
| throw resObj.error; | |
| } | |
| return true; | |
| } catch (err) { | |
| lastError = err; | |
| try { | |
| console.warn('[AuthContext] updatePassword attempt failed', { | |
| attempt, | |
| error: err instanceof Error ? err.message : String(err), | |
| }); | |
| } catch { | |
| /* ignore */ | |
| } | |
| if (attempt === 2) throw err; | |
| // small backoff then retry | |
| await new Promise((r) => setTimeout(r, 200)); | |
| } | |
| } | |
| throw lastError; | |
| }, | |
| {}, | |
| ); | |
| }, | |
| [supabase], | |
| ); | |
| const resendConfirmation = useCallback( | |
| async (email: string) => { | |
| if (!supabase) throw new Error('Authentication is not available in offline mode.'); | |
| await withSupabaseClientErrorHandling( | |
| 'resendConfirmation', | |
| async () => { | |
| const { error } = await supabase!.auth.resend({ | |
| type: 'signup', | |
| email, | |
| }); | |
| if (error) throw error; | |
| return true; | |
| }, | |
| { email }, | |
| ); | |
| }, | |
| [supabase], | |
| ); | |
| const value = useMemo( | |
| () => ({ | |
| user, | |
| loading, | |
| isSupabaseAvailable, | |
| signUp, | |
| signIn, | |
| signOut, | |
| resetPassword, | |
| updatePassword, | |
| resendConfirmation, | |
| }), | |
| [ | |
| user, | |
| loading, | |
| isSupabaseAvailable, | |
| signUp, | |
| signIn, | |
| signOut, | |
| resetPassword, | |
| updatePassword, | |
| resendConfirmation, | |
| ], | |
| ); | |
| return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment