/** * ============================================================================= * AUTH SLICE - Authentication State Management * ============================================================================= * * Handles user authentication state with Supabase Auth. * * REDUX TOOLKIT CONCEPTS: * * 1. SLICES: * A "slice" is a cohesive Redux module bundling: * - State shape (interface AuthState) * - Initial state (initialState) * - Reducers (synchronous actions: setAuthState, clearAuthError, resetAuthState) * - Extra reducers (async actions: sign in, sign up, sign out) * - Selectors (derived state queries) * * WHY: Keeps related state, mutations, and queries in one place. No scattered * action creators, reducers, and action types across separate files. * * SEMANTIC MEANING: A slice is a "self-contained state domain"—auth slice * owns all authentication logic and UI state. Another slice might own todos, * cache, UI, etc. Each is independent but can be composed in the store. * * 2. ASYNC THUNKS: * An "async thunk" is a Redux action that: * - Accepts parameters (email, password) * - Dispatches three predictable actions: pending, fulfilled, rejected * - Runs side effects (API calls, I/O) and returns a value or error * - Automatically generates loading/error state transitions * * WHY: Thunks solve the "how do I handle async operations in Redux?" * problem. Instead of manually dispatching actions before/after API calls, * thunks wrap this pattern. Framework handles loading states automatically. * * SEMANTIC FLOW: * User calls dispatch(signInWithEmail({email, password})) * → pending action fires → loading = true * → API call to Supabase runs * → fulfilled action fires → loading = false, user = result * (or rejected action → loading = false, error = message) * * ALTERNATIVE: Without thunks, you'd manually dispatch: * dispatch(signInPending()) * const result = await api.signIn(...) * dispatch(signInFulfilled(result)) or dispatch(signInRejected(error)) * Thunks automate this boilerplate. * * INTERVIEW NOTES: * - Supabase Auth provides email/password, OAuth, and magic link auth * - Sessions are automatically persisted in localStorage * - The auth state listener keeps Redux in sync with Supabase * - Thunks are created with createAsyncThunk (Redux Toolkit utility) * - extraReducers handle thunk actions (pending, fulfilled, rejected) */ import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'; import { supabase } from '@/utils/supabase'; import type { User, Session } from '@supabase/supabase-js'; import type { RootState } from '@/store'; // ============================================================================= // STATE INTERFACE // ============================================================================= interface AuthState { /** Current authenticated user */ user: User | null; /** Current session */ session: Session | null; /** Whether we've checked for existing session */ initialized: boolean; /** Loading state for auth operations */ loading: boolean; /** Error message if auth fails */ error: string | null; } // ============================================================================= // INITIAL STATE // ============================================================================= const initialState: AuthState = { user: null, session: null, initialized: false, loading: false, error: null, }; // ============================================================================= // ASYNC THUNKS - Side Effects & API Calls // ============================================================================= /** * WHAT IS AN ASYNC THUNK? * * A thunk is a Redux action that handles asynchronous side effects (API calls, * timers, I/O). It's created with createAsyncThunk(actionType, asyncFunction). * * Redux Toolkit automatically generates three dispatch cases: * 1. pending - Called before async operation (set loading = true) * 2. fulfilled - Called on success (set data, loading = false) * 3. rejected - Called on error (set error, loading = false) * * WHY NOT JUST ASYNC/AWAIT? * * Bad approach (before Redux Toolkit): * ```typescript * // Component dispatches before/after manually * dispatch({ type: 'auth/signInStart' }); * const user = await api.signIn(email, password); * dispatch({ type: 'auth/signInSuccess', payload: user }); * ``` * * Good approach (with thunks): * ```typescript * // Component just dispatches thunk; middleware handles pending/fulfilled/rejected * dispatch(signInWithEmail({email, password})); * // Reducer automatically handles loading/error state * ``` * * BENEFITS: * - Automatic loading state management * - Consistent error handling pattern * - Declarative (describe "what" not "how") * - Reusable across components * - Easily testable */ /** * Initialize auth state by checking for existing session * * WHEN TO USE: Call this once on app startup (in useEffect or main.tsx). * PURPOSE: Hydrate Redux with Supabase session if user is already logged in * (from localStorage or cookies). * * EXAMPLE: * ```typescript * useEffect(() => { * dispatch(initializeAuth()); * }, [dispatch]); * * // Redux state updates: * // initializeAuth.pending → loading = true * // initializeAuth.fulfilled → session = {...}, user = {...}, loading = false * // initializeAuth.rejected → error = "...", loading = false * ``` */ export const initializeAuth = createAsyncThunk( 'auth/initialize', async (_, { rejectWithValue }) => { try { const { data: { session }, error } = await supabase.auth.getSession(); if (error) throw error; return session; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to initialize auth'; return rejectWithValue(message); } } ); /** * Sign in with email and password * * WHEN TO USE: User submits login form. * PURPOSE: Call Supabase auth endpoint, update Redux with user/session. * FLOW: * 1. User fills email + password form * 2. Component: dispatch(signInWithEmail({email, password})) * 3. Thunk pending fires → loading = true * 4. Supabase API call (await supabase.auth.signInWithPassword) * 5. Thunk fulfilled → user, session in Redux, loading = false * 6. Component renders user dashboard * * ERROR HANDLING: * If API rejects (wrong password, user not found), thunk.rejected fires * → error message stored in Redux, component displays error * * EXAMPLE: * ```typescript * const { loading, error } = useAppSelector(state => state.auth); * const dispatch = useAppDispatch(); * * const handleLogin = async (email, password) => { * const result = await dispatch(signInWithEmail({email, password})); * if (result.meta.requestStatus === 'fulfilled') { * // Redirect to dashboard * navigate('/dashboard'); * } * }; * * * {error &&

{error}

} * ``` */ export const signInWithEmail = createAsyncThunk( 'auth/signInWithEmail', async ({ email, password }: { email: string; password: string }, { rejectWithValue }) => { try { const { data, error } = await supabase.auth.signInWithPassword({ email, password, }); if (error) throw error; return data; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to sign in'; return rejectWithValue(message); } } ); /** * Sign up with email and password * * WHEN TO USE: New user registration form. * PURPOSE: Create new Supabase auth account, optional metadata (name, profile). * METADATA: Extra data stored with user (not needed for auth, but useful * for user profiles, preferences, etc.) * * FLOW: Similar to signInWithEmail—dispatch thunk, pending/fulfilled/rejected * automatically update Redux state. * * NOTE: Supabase may require email confirmation (check email_confirmed flag * in extra reducers if email verification is enabled). * * EXAMPLE: * ```typescript * await dispatch(signUpWithEmail({ * email: 'user@example.com', * password: 'securepassword', * metadata: { * firstName: 'John', * lastName: 'Doe', * avatar: 'https://...' * } * })); * ``` */ export const signUpWithEmail = createAsyncThunk( 'auth/signUpWithEmail', async ({ email, password, metadata }: { email: string; password: string; metadata?: Record; }, { rejectWithValue }) => { try { const { data, error } = await supabase.auth.signUp({ email, password, options: { data: metadata, }, }); if (error) throw error; return data; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to sign up'; return rejectWithValue(message); } } ); /** * Sign in with OAuth provider (Google, GitHub, Discord, etc.) * * WHEN TO USE: "Sign in with Google" / "Sign in with GitHub" buttons. * PURPOSE: Redirect to OAuth provider, return authenticated via callback. * REDIRECT: After OAuth flow, Supabase redirects back to window.location.origin. * * FLOW: * 1. User clicks "Sign in with Google" * 2. dispatch(signInWithOAuth('google')) * 3. Browser redirects to Google OAuth login * 4. User authenticates with Google * 5. Google redirects back to your app * 6. Supabase auth listener picks up session * 7. Redux updates via setAuthState reducer * * NOTE: OAuth doesn't return data immediately (it redirects). The actual * session is picked up by the auth state listener subscription. * * EXAMPLE: * ```typescript * * ``` */ export const signInWithOAuth = createAsyncThunk( 'auth/signInWithOAuth', async (provider: 'google' | 'github' | 'discord', { rejectWithValue }) => { try { const { error } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo: window.location.origin, }, }); if (error) throw error; // OAuth redirects, so no return value needed return null; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to sign in with OAuth'; return rejectWithValue(message); } } ); /** * Sign out the current user * * WHEN TO USE: User clicks "Sign out" or session expires. * PURPOSE: Clear Supabase session, clear Redux user/session state. * RESULT: User no longer authenticated; redirect to login page. * * EXAMPLE: * ```typescript * * * // After fulfilled, component can redirect: * const authStatus = useAppSelector(selectIsAuthenticated); * useEffect(() => { * if (!authStatus) navigate('/login'); * }, [authStatus, navigate]); * ``` */ export const signOut = createAsyncThunk( 'auth/signOut', async (_, { rejectWithValue }) => { try { const { error } = await supabase.auth.signOut(); if (error) throw error; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to sign out'; return rejectWithValue(message); } } ); /** * Send password reset email * * WHEN TO USE: User clicks "Forgot password?" link. * PURPOSE: Send email with reset link. User clicks link, sets new password. * REDIRECT: Email contains link to /reset-password page on your app. * * EXAMPLE: * ```typescript * const handleForgotPassword = async (email) => { * const result = await dispatch(resetPassword(email)); * if (result.meta.requestStatus === 'fulfilled') { * alert('Check your email for reset link'); * } * }; * ``` */ export const resetPassword = createAsyncThunk( 'auth/resetPassword', async (email: string, { rejectWithValue }) => { try { const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: `${window.location.origin}/reset-password`, }); if (error) throw error; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to send reset email'; return rejectWithValue(message); } } ); /** * Update user password (requires current session) * * WHEN TO USE: User changes password in account settings. * PURPOSE: Update password in Supabase for authenticated user. * REQUIREMENT: User must be logged in (active session). * * EXAMPLE: * ```typescript * const handleChangePassword = async (oldPassword, newPassword) => { * // TODO: Verify oldPassword before allowing update (client-side check) * const result = await dispatch(updatePassword(newPassword)); * if (result.meta.requestStatus === 'fulfilled') { * alert('Password updated'); * } * }; * ``` */ export const updatePassword = createAsyncThunk( 'auth/updatePassword', async (newPassword: string, { rejectWithValue }) => { try { const { error } = await supabase.auth.updateUser({ password: newPassword, }); if (error) throw error; } catch (err) { const message = err instanceof Error ? err.message : 'Failed to update password'; return rejectWithValue(message); } } ); // ============================================================================= // SLICE DEFINITION - Synchronous & Asynchronous Reducers // ============================================================================= /** * SLICE ANATOMY: * * A slice combines state, reducers, and actions into one module: * * 1. name: 'auth' * Prefix for auto-generated action types (e.g., 'auth/setAuthState') * * 2. initialState * Default state shape before any actions * * 3. reducers: {...} * Synchronous actions (pure functions, no side effects) * Examples: setAuthState, clearAuthError (happen instantly) * * 4. extraReducers: (builder) => {...} * Asynchronous thunk handlers (pending, fulfilled, rejected) * Examples: Handle signInWithEmail.pending, .fulfilled, .rejected * * WHY SEPARATE reducers vs extraReducers? * - reducers: Synchronous state updates (instant, no side effects) * - extraReducers: Handle thunk lifecycle (loading state, API responses) * * RESULT: createSlice returns an object with: * - actions: {setAuthState, clearAuthError, resetAuthState} * - reducer: The combined reducer function * - asyncThunks: {initializeAuth, signInWithEmail, ...} */ const authSlice = createSlice({ name: 'auth', initialState, reducers: { /** * Synchronous reducer: Update auth state from Supabase listener * * WHY: When Supabase auth state changes (e.g., user logs in, logs out, * refreshes token), the subscription listener calls this to sync Redux. * * PURE FUNCTION: Takes state & action, returns new state. * No API calls, no side effects. * * EXAMPLE: * ```typescript * // In useEffect or main initialization: * supabase.auth.onAuthStateChange((event, session) => { * dispatch(setAuthState({ * user: session?.user ?? null, * session: session ?? null * })); * }); * ``` * * REDUX IMMER: Redux Toolkit uses Immer under the hood, so you can * mutate state directly (looks imperative but is actually immutable): * ```typescript * state.user = action.payload.user; // Safe! Immer makes copy * ``` */ setAuthState: (state, action: PayloadAction<{ user: User | null; session: Session | null }>) => { state.user = action.payload.user; state.session = action.payload.session; state.initialized = true; }, /** * Synchronous reducer: Clear error message * * WHY: User dismisses error toast/alert; clear error from Redux * so UI no longer shows it. * * EXAMPLE: * ```typescript * * ``` */ clearAuthError: (state) => { state.error = null; }, /** * Synchronous reducer: Reset entire auth state * * WHY: Called after successful sign out. Clears user, session, error * but keeps initialized = true (so we don't re-check session). * * EXAMPLE: * ```typescript * // In signOut thunk fulfilled case * dispatch(resetAuthState()); // Clears user, session * ``` */ resetAuthState: () => ({ ...initialState, initialized: true, }), }, extraReducers: (builder) => { /** * EXTRA REDUCERS: Handle async thunk lifecycle * * For each thunk (initializeAuth, signInWithEmail, etc.), * Redux Toolkit creates 3 actions: * 1. .pending → Dispatched when thunk starts * 2. .fulfilled → Dispatched when thunk succeeds (has payload) * 3. .rejected → Dispatched when thunk fails (has error) * * FLOW EXAMPLE (signInWithEmail): * ```typescript * dispatch(signInWithEmail({email, password})) * → addCase(signInWithEmail.pending, ...) * state.loading = true * → [await API call] * → addCase(signInWithEmail.fulfilled, ...) * state.user = action.payload.user * state.loading = false * ``` * * OR on error: * ```typescript * → addCase(signInWithEmail.rejected, ...) * state.error = "Wrong password" * state.loading = false * ``` */ builder // Initialize auth .addCase(initializeAuth.pending, (state) => { state.loading = true; }) .addCase(initializeAuth.fulfilled, (state, action) => { state.loading = false; state.initialized = true; state.session = action.payload; state.user = action.payload?.user ?? null; }) .addCase(initializeAuth.rejected, (state, action) => { state.loading = false; state.initialized = true; state.error = action.payload as string; }) // Sign in with email .addCase(signInWithEmail.pending, (state) => { state.loading = true; state.error = null; }) .addCase(signInWithEmail.fulfilled, (state, action) => { state.loading = false; state.session = action.payload.session; state.user = action.payload.user; }) .addCase(signInWithEmail.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; }) // Sign up with email .addCase(signUpWithEmail.pending, (state) => { state.loading = true; state.error = null; }) .addCase(signUpWithEmail.fulfilled, (state, action) => { state.loading = false; state.session = action.payload.session; state.user = action.payload.user; }) .addCase(signUpWithEmail.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; }) // Sign out .addCase(signOut.pending, (state) => { state.loading = true; }) .addCase(signOut.fulfilled, (state) => { state.loading = false; state.user = null; state.session = null; }) .addCase(signOut.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; }) // Reset password .addCase(resetPassword.pending, (state) => { state.loading = true; state.error = null; }) .addCase(resetPassword.fulfilled, (state) => { state.loading = false; }) .addCase(resetPassword.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; }); }, }); // ============================================================================= // SELECTORS - Query/Derive State // ============================================================================= /** * WHAT ARE SELECTORS? * * Selectors are functions that extract or derive data from Redux state. * They're like "getters" for state slices. * * WHY USE SELECTORS? * 1. Centralize state shape queries (single source of truth) * 2. Memoize derived data (Redux prevents re-renders) * 3. Refactor state shape without breaking components * 4. Easier testing and composition * * SIGNATURE: (state: RootState) => SelectedValue * * USAGE IN COMPONENTS: * ```typescript * const user = useAppSelector(selectUser); * const isAuth = useAppSelector(selectIsAuthenticated); * const error = useAppSelector(selectAuthError); * const loading = useAppSelector(selectAuthLoading); * ``` * * BENEFITS vs Direct Access: * Bad (tight coupling to state shape): * ```typescript * const user = useAppSelector(state => state.auth.user); * // If we rename state.auth.user → state.auth.profile, breaks all components * ``` * * Good (decoupled): * ```typescript * const user = useAppSelector(selectUser); * // Rename state.auth.user → state.auth.profile, only update selectUser * // All components still work! * ``` * * ADVANCED: Selectors can compose/derive: * ```typescript * export const selectIsGuest = (state: RootState) => * !selectIsAuthenticated(state); // Inverted auth status * * export const selectUserEmail = (state: RootState) => * selectUser(state)?.email ?? null; // Derived field * ``` */ /** Get current authenticated user object */ export const selectUser = (state: RootState) => state.auth.user; /** Get current session (includes access token, refresh token) */ export const selectSession = (state: RootState) => state.auth.session; /** Is user authenticated? (boolean derived from session existence) */ export const selectIsAuthenticated = (state: RootState) => !!state.auth.session; /** Is auth operation in progress? (sign in, sign up, sign out) */ export const selectAuthLoading = (state: RootState) => state.auth.loading; /** Error message from last failed auth operation */ export const selectAuthError = (state: RootState) => state.auth.error; /** Have we checked for existing session? (app initialization complete) */ export const selectAuthInitialized = (state: RootState) => state.auth.initialized; // ============================================================================= // EXPORTS // ============================================================================= export const { setAuthState, clearAuthError, resetAuthState } = authSlice.actions; export default authSlice.reducer;