diff --git a/src/App.tsx b/src/App.tsx index 59c76165..eb937e4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,8 @@ import ViewScores from "./pages/Assignments/ViewScores"; import ViewSubmissions from "./pages/Assignments/ViewSubmissions"; import SubmittedContent from "./pages/Assignments/SubmittedContent"; import Login from "./pages/Authentication/Login"; +import ForgotPassword from "./pages/Authentication/ForgotPassword"; +import ResetPassword from "./pages/Authentication/ResetPassword"; import Logout from "./pages/Authentication/Logout"; import Courses from "./pages/Courses/Course"; import CourseEditor from "./pages/Courses/CourseEditor"; @@ -63,6 +65,8 @@ function App() { children: [ { index: true, element: } /> }, { path: "login", element: }, + { path: "forgot-password", element: }, + { path: "password_edit/check_reset_url", element: }, { path: "logout", element: } /> }, { diff --git a/src/constants/Api.ts b/src/constants/Api.ts new file mode 100644 index 00000000..82dca5cf --- /dev/null +++ b/src/constants/Api.ts @@ -0,0 +1 @@ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3002"; \ No newline at end of file diff --git a/src/pages/Authentication/ForgotPassword.tsx b/src/pages/Authentication/ForgotPassword.tsx new file mode 100644 index 00000000..c8bcb12f --- /dev/null +++ b/src/pages/Authentication/ForgotPassword.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { Button, Col, Container } from "react-bootstrap"; +import { Form, Formik, FormikHelpers } from "formik"; +import FormInput from "../../components/Form/FormInput"; +import axios, { AxiosError } from "axios"; +import { alertActions } from "../../store/slices/alertSlice"; +import { useDispatch } from "react-redux"; +import { API_BASE_URL } from "../../constants/Api"; +import * as Yup from "yup"; + +interface IForgotPasswordFormValues { + email: string; +} + +const validationSchema = Yup.object({ + email: Yup.string().trim().email("Invalid email address").required("Required"), +}); + +const ForgotPassword = () => { + const dispatch = useDispatch(); + + const onSubmit = async ( + values: IForgotPasswordFormValues, + submitProps: FormikHelpers + ) => { + try { + await axios.post(`${API_BASE_URL}/password_resets`, { email: values.email }); + dispatch( + alertActions.showAlert({ + variant: "success", + message: "A link to reset your password has been sent to your e-mail address.", + }) + ); + } catch (error) { + let errorFallback = "An error occurred. Please try again."; + if (error instanceof AxiosError && error.response && error.response.data) { + const { error: errorMessage } = error.response.data; + errorFallback = errorMessage || errorFallback; + } + dispatch( + alertActions.showAlert({ + variant: "danger", + message: errorFallback, + }) + ); + } + submitProps.setSubmitting(false); + }; + + return ( + + +

Forgotten Your Password?

+ + {(formik) => ( +
+

Enter the email associated with your account

+ + + + )} +
+ +
+ ); +}; + +export default ForgotPassword; \ No newline at end of file diff --git a/src/pages/Authentication/Login.tsx b/src/pages/Authentication/Login.tsx index 7c02cad6..17d7a60c 100644 --- a/src/pages/Authentication/Login.tsx +++ b/src/pages/Authentication/Login.tsx @@ -57,7 +57,7 @@ const Login: React.FC = () => { }; return ( - +

Login

{ + const location = useLocation(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const queryParams = new URLSearchParams(location.search); + const token = queryParams.get("token"); + + // Ensure the token is present when the component mounts + useEffect(() => { + if (!token) { + dispatch( + alertActions.showAlert({ + variant: "danger", + message: "Invalid or missing token.", + }) + ); + navigate("/login"); + } + }, [token, dispatch, navigate]); + + const onSubmit = async ( + values: IResetPasswordFormValues, + submitProps: FormikHelpers + ) => { + try { + // Send password reset request to the backend + await axios.put(`${API_BASE_URL}/password_resets/${token}`, { + user: { password: values.password }, + }); + dispatch( + alertActions.showAlert({ + variant: "success", + message: "Password Successfully Updated", + }) + ); + navigate("/login"); + } catch (error) { + let errorFallback = "An error occurred. Please try again."; + if (error instanceof AxiosError && error.response && error.response.data) { + const { error: errorMessage } = error.response.data; + errorFallback = errorMessage || errorFallback; + } + dispatch( + alertActions.showAlert({ + variant: "danger", + message: errorFallback, + }) + ); + } + submitProps.setSubmitting(false); + }; + + return ( + + +

Reset Your Password

+ + {(formik) => ( +
+ + + + + )} +
+ +
+ ); +}; + +export default ResetPassword; diff --git a/src/pages/Authentication/__tests__/ForgotPassword.test.tsx b/src/pages/Authentication/__tests__/ForgotPassword.test.tsx new file mode 100644 index 00000000..a049c235 --- /dev/null +++ b/src/pages/Authentication/__tests__/ForgotPassword.test.tsx @@ -0,0 +1,159 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ForgotPassword from "../ForgotPassword"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import alertReducer from "store/slices/alertSlice"; +import { vi } from "vitest"; +import axios from "axios"; +import { AxiosError } from "axios"; + +vi.mock('axios'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const makeMockStore = () => configureStore({ + reducer: { + alert: alertReducer, + }, +}); + +const validEmail = 'test@example.com'; +const submitText = /request password reset/i; + +describe('Test Forgot Password Displays Correctly', () => { + it('Renders the component correctly', () => { + const store = makeMockStore(); + render( + + + + ); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(/forgotten your password\?/i); + expect(screen.getByText(/enter the email associated with your account/i)).toBeInTheDocument(); + expect(screen.getByRole('button', {name: submitText})).toBeInTheDocument(); + }); + + it('Renders email input field', () => { + const store = makeMockStore(); + render( + + + + ); + const emailInput = screen.getByRole('textbox', {name: /email address/i}); + expect(emailInput).toBeInTheDocument(); + }); +}); + +describe('Test Forgot Password Form Validations', () => { + it('Does not submit form with empty email', async () => { + const user = userEvent.setup(); + const store = makeMockStore(); + render( + + + + ); + + let emailInput = screen.getByRole('textbox'); + let submitButton = screen.getByRole('button', {name: submitText}); + + await user.click(emailInput); + await user.tab(); + await user.click(submitButton); + + expect(axios.post).not.toHaveBeenCalled(); + + expect(screen.getByText(/required/i)).toBeInTheDocument(); + }); + + it('Does not submit form with invalid email', async () => { + const user = userEvent.setup(); + const store = makeMockStore(); + render( + + + + ); + + let emailInput = screen.getByRole('textbox'); + let submitButton = screen.getByRole('button', {name: submitText}); + + await user.type(emailInput, 'bademail'); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText(/invalid email address/i)).toBeInTheDocument(); + }); + expect(submitButton).toBeDisabled(); + expect(axios.post).not.toHaveBeenCalled(); + }); +}); + +describe('Test Forgot Password Api Error', () => { + it('Handles API unavailable', async () => { + const user = userEvent.setup(); + (axios.post as any).mockRejectedValue( + new AxiosError("Network Error", 'ERR_NETWORK') + ); + + const store = makeMockStore(); + render( + + + + ); + + let emailInput = screen.getByRole('textbox'); + let submitButton = screen.getByRole('button', {name: submitText}); + + await user.type(emailInput, validEmail); + await user.click(submitButton); + + await waitFor(() => { + const state = store.getState(); + expect(state.alert.message).toBe('An error occurred. Please try again.'); + expect(state.alert.variant).toBe('danger'); + }); + + expect(axios.post).toHaveBeenCalledWith( + expect.stringContaining('/password_resets'), {email: validEmail} + ); + }); +}); + +describe('Test Successful Password Reset Request', () => { + it('submit form successfully', async () => { + const user = userEvent.setup(); + (axios.post as any).mockResolvedValue({ + status: 200, + data: { message: 'If the email exists, a reset link has been sent.'}, + }); + const store = makeMockStore(); + render( + + + + ); + + let emailInput = screen.getByRole('textbox'); + let submitButton = screen.getByRole('button', {name: submitText}); + + await user.type(emailInput, validEmail); + await user.click(submitButton); + + expect(axios.post).toHaveBeenCalledWith( + expect.stringContaining('/password_resets'), {email: validEmail} + ); + + await waitFor(() => { + const state = store.getState(); + expect(state.alert.variant).toBe('success'); + expect(state.alert.message).toBe('A link to reset your password has been sent to your e-mail address.'); + }); + }); +}); diff --git a/src/pages/Authentication/__tests__/ResetPassword.test.tsx b/src/pages/Authentication/__tests__/ResetPassword.test.tsx new file mode 100644 index 00000000..8d916761 --- /dev/null +++ b/src/pages/Authentication/__tests__/ResetPassword.test.tsx @@ -0,0 +1,220 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ResetPassword from "../ResetPassword"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import alertReducer from "store/slices/alertSlice"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { vi } from "vitest"; +import axios from "axios"; +import { AxiosError } from "axios"; + +vi.mock("axios"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const makeMockStore = () => + configureStore({ + reducer: { + alert: alertReducer, + }, + }); + +// Renders ResetPassword inside the required Provider + Router context. +// Returns the store so each test can inspect its own isolated alert state. +const renderComponent = (token: string | null = "valid-token") => { + const store = makeMockStore(); + const search = token ? `?token=${token}` : ""; + render( + + + + } /> + {/* Provide a /login route so navigate('/login') doesn't throw */} + Login Page} /> + + + + ); + return store; +}; + +describe("Test Reset Password Missing Token", () => { + it("redirects to login and shows error when token is missing", async () => { + const store = renderComponent(null); + + await waitFor(() => { + const state = store.getState(); + expect(state.alert.variant).toBe("danger"); + expect(state.alert.message).toBe("Invalid or missing token."); + }); + + // The component should have navigated away — login page is rendered instead + expect(await screen.findByText(/login page/i)).toBeInTheDocument(); + }); +}); + +describe("Test Reset Password Displays Correctly", () => { + it("renders the component correctly", () => { + renderComponent(); + + // The page heading should be present + expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( + /reset your password/i + ); + + // Both password fields must be in the document + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); + + // The submit button must be present + expect( + screen.getByRole("button", { name: /reset password/i }) + ).toBeInTheDocument(); + }); + + it("submit button is disabled when fields are empty", () => { + renderComponent(); + + // Formik's `disabled={!(formik.isValid && formik.dirty)}` means the button + // should be disabled on initial render before the user touches anything. + expect( + screen.getByRole("button", { name: /reset password/i }) + ).toBeDisabled(); + }); +}); + +const validPassword = "validpassword"; + +describe("Test Reset Password Form Validations", () => { + it("does not submit form when password is too short", async () => { + const user = userEvent.setup(); + renderComponent(); + + const passwordInput = screen.getByLabelText(/^password$/i); + const submitButton = screen.getByRole("button", { name: /reset password/i }); + + await user.type(passwordInput, "abc"); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText(/password must be at least 6 characters/i)).toBeInTheDocument(); + }); + expect(submitButton).toBeDisabled(); + expect(axios.put).not.toHaveBeenCalled(); + }); + + it("does not submit form when passwords do not match", async () => { + const user = userEvent.setup(); + renderComponent(); + + const passwordInput = screen.getByLabelText(/^password$/i); + const confirmInput = screen.getByLabelText(/confirm password/i); + const submitButton = screen.getByRole("button", { name: /reset password/i }); + + await user.type(passwordInput, validPassword); + await user.type(confirmInput, "differentpassword"); + await user.tab(); // Simulates pressing tab to trigger validation with both fields filled + + await waitFor(() => { + expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument(); + }); + expect(submitButton).toBeDisabled(); + expect(axios.put).not.toHaveBeenCalled(); + }); +}); + +describe("Test Successful Password Reset", () => { + it("submits form successfully", async () => { + const user = userEvent.setup(); + (axios.put as any).mockResolvedValue({ + status: 200, + data: { message: "Password Successfully Updated" }, + }); + + const store = renderComponent(); + + const passwordInput = screen.getByLabelText(/^password$/i); + const confirmInput = screen.getByLabelText(/confirm password/i); + const submitButton = screen.getByRole("button", { name: /reset password/i }); + + await user.type(passwordInput, validPassword); + await user.type(confirmInput, validPassword); + await user.tab(); // simulates pressing tab to trigger validation with both fields filled + await user.click(submitButton); + + await waitFor(() => { + expect(axios.put).toHaveBeenCalledWith( + expect.stringContaining("/password_resets/valid-token"), + { user: { password: validPassword } } + ); + const state = store.getState(); + expect(state.alert.variant).toBe("success"); + expect(state.alert.message).toBe("Password Successfully Updated"); + }); + }); +}); + +describe("Test Reset Password Api Error", () => { + it("handles API unavailable", async () => { + const user = userEvent.setup(); + (axios.put as any).mockRejectedValue( + new AxiosError("Network Error", "ERR_NETWORK") + ); + + const store = renderComponent(); + + const passwordInput = screen.getByLabelText(/^password$/i); + const confirmInput = screen.getByLabelText(/confirm password/i); + const submitButton = screen.getByRole("button", { name: /reset password/i }); + + await user.type(passwordInput, validPassword); + await user.type(confirmInput, validPassword); + await user.tab(); // Simulates pressing tab to trigger validation with both fields filled + await user.click(submitButton); + + await waitFor(() => { + const state = store.getState(); + expect(state.alert.message).toBe("An error occurred. Please try again."); + expect(state.alert.variant).toBe("danger"); + }); + + expect(axios.put).toHaveBeenCalledWith( + expect.stringContaining("/password_resets/valid-token"), + { user: { password: validPassword } } + ); + }); + + it("shows server error message when API returns one", async () => { + const user = userEvent.setup(); + const serverError = new AxiosError("Token has expired.", "ERR_BAD_REQUEST"); + (serverError as any).response = { + status: 422, + statusText: "Unprocessable Entity", + data: { error: "Token has expired." }, + headers: {}, + config: {} as any, + }; + (axios.put as any).mockRejectedValue(serverError); + + const store = renderComponent(); + + const passwordInput = screen.getByLabelText(/^password$/i); + const confirmInput = screen.getByLabelText(/confirm password/i); + const submitButton = screen.getByRole("button", { name: /reset password/i }); + + await user.type(passwordInput, validPassword); + await user.type(confirmInput, validPassword); + await user.tab(); + await user.click(submitButton); + + await waitFor(() => { + const state = store.getState(); + expect(state.alert.message).toBe("Token has expired."); + expect(state.alert.variant).toBe("danger"); + }); + }); +});