Skip to content

Instantly share code, notes, and snippets.

@andymagill
Last active September 5, 2025 16:28
Show Gist options
  • Select an option

  • Save andymagill/082d7389d9a03d16a234f0607adf0506 to your computer and use it in GitHub Desktop.

Select an option

Save andymagill/082d7389d9a03d16a234f0607adf0506 to your computer and use it in GitHub Desktop.
AuthContext Provider for Supabase in Next.js
/**
* 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