Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/features/auth/authSelectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
45 changes: 40 additions & 5 deletions src/features/auth/authSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const initialState = {
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
error: null,
fieldErrors: {},
};

const authSlice = createSlice({
Expand All @@ -19,49 +20,83 @@ const authSlice = createSlice({
state.token = token;
state.isAuthenticated = true;
state.error = null;
state.fieldErrors = {};
},
clearCredentials: (state) => {
state.user = null;
state.token = null;
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;
state.user = action.payload.user;
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;
Expand Down Expand Up @@ -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;
44 changes: 40 additions & 4 deletions src/features/auth/authThunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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);
}
}
);
Expand All @@ -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);
}
}
);
Expand Down
65 changes: 36 additions & 29 deletions src/pages/Login.jsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 (
Expand Down Expand Up @@ -124,8 +130,9 @@ const Login = () => {
</div>

{/* Error Message */}
{error && (
{reduxError && (
<div
role="alert"
style={{
background: "#fee2e2",
border: "1px solid #fecaca",
Expand All @@ -135,7 +142,7 @@ const Login = () => {
color: "#dc2626",
}}
>
{error}
{reduxError}
</div>
)}

Expand All @@ -146,7 +153,7 @@ const Login = () => {
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={handleEmailChange}
autoComplete="email"
/>

Expand All @@ -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"
/>
Expand Down
Loading
Loading