From 6a91f12f3b9edde7cd2e57cdc94193cbfcef69e2 Mon Sep 17 00:00:00 2001 From: sudiptapaul Date: Tue, 23 Jun 2026 07:51:12 +0530 Subject: [PATCH] feat(auth): Add field-level error handling and improved error parsing --- src/features/auth/authSelectors.js | 1 + src/features/auth/authSlice.js | 45 ++++++++++++++++++--- src/features/auth/authThunks.js | 44 ++++++++++++++++++-- src/pages/Login.jsx | 65 +++++++++++++++++------------- src/pages/Register.jsx | 64 ++++++++++++++++++++++++----- 5 files changed, 170 insertions(+), 49 deletions(-) diff --git a/src/features/auth/authSelectors.js b/src/features/auth/authSelectors.js index 8db6239..97bac01 100644 --- a/src/features/auth/authSelectors.js +++ b/src/features/auth/authSelectors.js @@ -3,3 +3,4 @@ export const selectIsAuthenticated = (state) => state.auth.isAuthenticated; export const selectAuthLoading = (state) => state.auth.isLoading; export const selectAuthError = (state) => state.auth.error; export const selectAuthToken = (state) => state.auth.token; +export const selectAuthFieldErrors = (state) => state.auth.fieldErrors; diff --git a/src/features/auth/authSlice.js b/src/features/auth/authSlice.js index 9d4a341..ba60a9c 100644 --- a/src/features/auth/authSlice.js +++ b/src/features/auth/authSlice.js @@ -6,7 +6,8 @@ const initialState = { token: null, isAuthenticated: false, isLoading: false, - error: null, + error: null, + fieldErrors: {}, }; const authSlice = createSlice({ @@ -19,6 +20,7 @@ const authSlice = createSlice({ state.token = token; state.isAuthenticated = true; state.error = null; + state.fieldErrors = {}; }, clearCredentials: (state) => { state.user = null; @@ -26,31 +28,56 @@ const authSlice = createSlice({ state.isAuthenticated = false; state.isLoading = false; state.error = null; + state.fieldErrors = {}; }, setAuthLoading: (state, action) => { state.isLoading = action.payload; }, setAuthError: (state, action) => { - state.error = action.payload; + const payload = action.payload; + if (payload && typeof payload === 'object') { + state.error = payload.field ? null : (payload.message ?? null); + state.fieldErrors = payload.field + ? { [payload.field]: payload.message } + : {}; + } else { + state.error = payload ?? null; + state.fieldErrors = {}; + } state.isLoading = false; }, + clearAuthError: (state) => { + state.error = null; + state.fieldErrors = {}; + }, }, extraReducers: (builder) => { builder .addCase(registerUser.pending, (state) => { state.isLoading = true; state.error = null; + state.fieldErrors = {}; }) .addCase(registerUser.fulfilled, (state) => { state.isLoading = false; + state.error = null; + state.fieldErrors = {}; }) .addCase(registerUser.rejected, (state, action) => { state.isLoading = false; - state.error = action.payload; + const payload = action.payload; + if (payload?.field) { + state.error = null; + state.fieldErrors = { [payload.field]: payload.message }; + } else { + state.error = payload?.message ?? payload ?? null; + state.fieldErrors = {}; + } }) .addCase(loginUser.pending, (state) => { state.isLoading = true; state.error = null; + state.fieldErrors = {}; }) .addCase(loginUser.fulfilled, (state, action) => { state.isLoading = false; @@ -58,10 +85,18 @@ const authSlice = createSlice({ state.token = action.payload.token; state.isAuthenticated = true; state.error = null; + state.fieldErrors = {}; }) .addCase(loginUser.rejected, (state, action) => { state.isLoading = false; - state.error = action.payload; + const payload = action.payload; + if (payload?.field) { + state.error = null; + state.fieldErrors = { [payload.field]: payload.message }; + } else { + state.error = payload?.message ?? payload ?? null; + state.fieldErrors = {}; + } }) .addCase(logoutUser.pending, (state) => { state.isLoading = true; @@ -153,5 +188,5 @@ const authSlice = createSlice({ }, }); -export const { setCredentials, clearCredentials, setAuthLoading, setAuthError } = authSlice.actions; +export const { setCredentials, clearCredentials, setAuthLoading, setAuthError, clearAuthError } = authSlice.actions; export default authSlice.reducer; diff --git a/src/features/auth/authThunks.js b/src/features/auth/authThunks.js index 3555208..2855d2c 100644 --- a/src/features/auth/authThunks.js +++ b/src/features/auth/authThunks.js @@ -2,6 +2,42 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import api from '../../services/api'; import { toastSuccess, toastError } from '../../utils/toast'; +const parseAuthError = (err) => { + const data = err.response?.data; + const code = data?.code || data?.error; + const serverMessage = data?.message || (typeof data === 'string' ? data : null); + + const codeMap = { + EMAIL_NOT_VERIFIED: { + message: 'Please verify your email before signing in. Check your inbox for a verification link.', + }, + INVALID_CREDENTIALS: { + message: 'Incorrect email or password. Please try again.', + }, + EMAIL_TAKEN: { + message: 'This email is already taken. Try signing in instead.', + field: 'email', + }, + USER_NOT_FOUND: { + message: 'No account found with that email address.', + field: 'email', + }, + INVALID_TOKEN: { + message: 'This link is invalid or has expired. Please request a new one.', + }, + TOKEN_EXPIRED: { + message: 'This link has expired. Please request a new one.', + }, + }; + + if (code && codeMap[code]) { + return codeMap[code]; + } + + // Fall back to whatever message the server sent + return { message: serverMessage || 'Something went wrong. Please try again.' }; +}; + export const registerUser = createAsyncThunk( 'auth/register', async (userData, { rejectWithValue }) => { @@ -11,8 +47,8 @@ export const registerUser = createAsyncThunk( // return response.data; return { status: 'success', message: 'Registration successful' }; } catch (err) { - toastError(err); - return rejectWithValue(err.response?.data || err.message); + const parsed = parseAuthError(err); + return rejectWithValue(parsed); } } ); @@ -25,8 +61,8 @@ export const loginUser = createAsyncThunk( toastSuccess('Logged in successfully'); return response.data; } catch (err) { - toastError(err); - return rejectWithValue(err.response?.data || err.message); + const parsed = parseAuthError(err); + return rejectWithValue(parsed); } } ); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 4fd48c7..8e56983 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -1,12 +1,14 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { Button } from "../components/common/button"; import Input from "../components/common/input"; import PasswordInput from "../components/common/passwordInput"; import { FcGoogle } from "react-icons/fc"; import { SiStellar } from "react-icons/si"; import { loginUser } from "../features/auth/authThunks"; +import { clearAuthError } from "../features/auth/authSlice"; +import { selectAuthError, selectAuthLoading } from "../features/auth/authSelectors"; const Login = () => { const navigate = useNavigate(); @@ -15,36 +17,40 @@ const Login = () => { const [email, setEmail] = useState(""); const [password, setPassword] = useState("········"); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + + const loading = useSelector(selectAuthLoading); + const reduxError = useSelector(selectAuthError); + + // Clear Redux error whenever the user starts typing + const handleEmailChange = useCallback((e) => { + setEmail(e.target.value); + if (reduxError) dispatch(clearAuthError()); + }, [reduxError, dispatch]); + + const handlePasswordChange = useCallback((e) => { + setPassword(e.target.value); + if (reduxError) dispatch(clearAuthError()); + }, [reduxError, dispatch]); + + // Clear error on unmount so stale errors don't bleed into other pages + useEffect(() => { + return () => { dispatch(clearAuthError()); }; + }, [dispatch]); const handleSubmit = useCallback(async () => { if (!email || password === "········") { - setError("Please enter your email and password"); + dispatch({ type: 'auth/setAuthError', payload: 'Please enter your email and password' }); return; } - setError(null); - setLoading(true); - - try { - const result = await dispatch(loginUser({ email, password })); - - if (result.payload) { - // Check for redirect query parameter - const redirectParam = searchParams.get('redirect'); - const redirectPath = redirectParam ? decodeURIComponent(redirectParam) : '/dashboard'; - navigate(redirectPath); - } else if (result.payload?.message) { - setError(result.payload.message); - } else { - setError("Login failed. Please try again."); - } - } catch { - setError("An error occurred. Please try again."); - } finally { - setLoading(false); + const result = await dispatch(loginUser({ email, password })); + + if (loginUser.fulfilled.match(result)) { + const redirectParam = searchParams.get('redirect'); + const redirectPath = redirectParam ? decodeURIComponent(redirectParam) : '/dashboard'; + navigate(redirectPath); } + // On rejection, the error is already in Redux state via extraReducers }, [email, password, dispatch, navigate, searchParams]); return ( @@ -124,8 +130,9 @@ const Login = () => { {/* Error Message */} - {error && ( + {reduxError && (
{ color: "#dc2626", }} > - {error} + {reduxError}
)} @@ -146,7 +153,7 @@ const Login = () => { type="email" placeholder="you@example.com" value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={handleEmailChange} autoComplete="email" /> @@ -155,7 +162,7 @@ const Login = () => { label="Password" id="password" value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={handlePasswordChange} showStrength={false} autoComplete="current-password" /> diff --git a/src/pages/Register.jsx b/src/pages/Register.jsx index 72c2998..36fcee9 100644 --- a/src/pages/Register.jsx +++ b/src/pages/Register.jsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { Link } from 'react-router-dom'; import { Button } from "../components/common/button"; import Input from "../components/common/input"; @@ -11,14 +11,19 @@ import { SiStellar } from "react-icons/si"; import { useForm, Controller } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { useNavigate } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { registerUser } from '../features/auth/authThunks'; import { registerSchema } from '../features/auth/authValidation'; +import { clearAuthError } from '../features/auth/authSlice'; +import { selectAuthError, selectAuthFieldErrors } from '../features/auth/authSelectors'; const Register = () => { const navigate = useNavigate(); const dispatch = useDispatch(); + const reduxError = useSelector(selectAuthError); + const fieldErrors = useSelector(selectAuthFieldErrors); + const { control, handleSubmit, @@ -34,19 +39,33 @@ const Register = () => { }, }); + // Clear Redux errors on unmount so stale errors don't persist + useEffect(() => { + return () => { dispatch(clearAuthError()); }; + }, [dispatch]); + const handleCreate = useCallback( async (data) => { - try { - await dispatch(registerUser(data)).unwrap(); + const result = await dispatch(registerUser(data)); + if (registerUser.fulfilled.match(result)) { navigate('/login'); - } catch (err) { - // toast already shown by thunk - console.log(err) } + // On rejection, error/fieldErrors are set in Redux state via extraReducers }, [dispatch, navigate] ); + // Clear Redux errors when the user starts typing in any field + const handleFieldChange = useCallback( + (originalOnChange) => (e) => { + if (reduxError || Object.keys(fieldErrors).length > 0) { + dispatch(clearAuthError()); + } + originalOnChange(e); + }, + [reduxError, fieldErrors, dispatch] + ); + return (
{ render={({ field }) => ( { /> )} /> - {errors.email && ( -

{errors.email.message}

+ {/* Show yup validation error OR backend field error for email */} + {(errors.email || fieldErrors.email) && ( +

+ {fieldErrors.email || errors.email?.message} +

)} {/* Role selection */} @@ -140,7 +163,7 @@ const Register = () => { value="donor" label="Support projects (Donor)" checked={field.value === "donor"} - onChange={() => field.onChange("donor")} + onChange={() => { handleFieldChange(() => field.onChange("donor"))(); }} /> { value="creator" label="Create a campaign (Creator)" checked={field.value === "creator"} - onChange={() => field.onChange("creator")} + onChange={() => { handleFieldChange(() => field.onChange("creator"))(); }} />
)} @@ -164,6 +187,7 @@ const Register = () => { render={({ field }) => ( { render={({ field }) => ( {

{errors.confirmPassword.message}

)} + {/* General backend error alert — shown above the submit button */} + {reduxError && ( +
+ {reduxError} +
+ )} + {/* Primary CTA */}