diff --git a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx index d2908a10f6..6518a79e9e 100644 --- a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx +++ b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx @@ -1,9 +1,15 @@ import React, { useState } from "react"; -import { Col, FormFeedback, FormGroup, Label, Row, UncontrolledTooltip } from "reactstrap"; -import { PasswordFeedback, ValidationUser } from "../../../../IsaacAppTypes"; +import { Col, FormGroup, Label, Row, UncontrolledTooltip } from "reactstrap"; +import { ValidationUser } from "../../../../IsaacAppTypes"; import { Immutable } from "immer"; -import { PASSWORD_REQUIREMENTS, passwordDebounce, validatePassword } from "../../../services"; +import { + PASSWORD_REQUIREMENTS, + validatePassword, + validatePasswordMatch, + getPasswordValidationErrors, +} from "../../../services"; import Password from "./Password"; + interface PasswordInputProps { userToUpdate: Immutable; setUserToUpdate: (user: Immutable) => void; @@ -22,9 +28,19 @@ export const RegistrationPasswordInputs = ({ defaultPassword, }: PasswordInputProps) => { const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const [passwordFeedback, setPasswordFeedback] = useState(null); + const [passwordTouched, setPasswordTouched] = useState(false); + const [confirmTouched, setConfirmTouched] = useState(false); + + const passwordIsValid = validatePassword(unverifiedPassword || ""); + const passwordsMatch = validatePasswordMatch(unverifiedPassword || "", userToUpdate.password || ""); + const bothPasswordsValid = passwordIsValid && passwordsMatch; + + // Get specific validation errors for display + const passwordErrors = unverifiedPassword ? getPasswordValidationErrors(unverifiedPassword) : []; - const passwordIsValid = userToUpdate.password == unverifiedPassword && validatePassword(userToUpdate.password || ""); + // Show errors if submission attempted OR if user has touched the field and there are errors + const showPasswordErrors = (submissionAttempted || passwordTouched) && !!unverifiedPassword && !passwordIsValid; + const showMatchError = (submissionAttempted || confirmTouched) && !!userToUpdate.password && !passwordsMatch; return ( @@ -42,21 +58,49 @@ export const RegistrationPasswordInputs = ({ isPasswordVisible={isPasswordVisible} setIsPasswordVisible={setIsPasswordVisible} defaultValue={defaultPassword} + invalid={showPasswordErrors ? true : undefined} onChange={(e) => { - passwordDebounce(e.target.value, setPasswordFeedback); + const newValue = e.target.value; + setUnverifiedPassword(newValue); + // Mark as touched when user starts typing + if (newValue.length > 0) { + setPasswordTouched(true); + } }} onBlur={(e) => { - setUnverifiedPassword(e.target.value); - passwordDebounce(e.target.value, setPasswordFeedback); + const newValue = e.target.value; + setUnverifiedPassword(newValue); + setPasswordTouched(true); }} showToggleIcon={true} required={true} /> - {passwordFeedback && ( - - Password strength: - {passwordFeedback.feedbackText} - + + {/* Show validation errors */} + {showPasswordErrors && passwordErrors.length > 0 && ( +
+ Password requirements: +
    + {passwordErrors.map((error) => { + const parts = error.split(/\n/); + return ( +
  • + {parts.map((line, i) => ( + + {i > 0 &&
    } + {line} +
    + ))} +
  • + ); + })} +
+
+ )} + + {/* If no password entered on submit */} + {submissionAttempted && (!unverifiedPassword || unverifiedPassword.length === 0) && ( +
Password is required
)} @@ -70,20 +114,30 @@ export const RegistrationPasswordInputs = ({ isPasswordVisible={isPasswordVisible} setIsPasswordVisible={setIsPasswordVisible} disabled={!unverifiedPassword} - invalid={submissionAttempted && !passwordIsValid} + invalid={showMatchError || (submissionAttempted && !bothPasswordsValid) ? true : undefined} onChange={(e: React.ChangeEvent) => { setUserToUpdate({ ...userToUpdate, password: e.target.value }); + if (e.target.value.length > 0) { + setConfirmTouched(true); + } + }} + onBlur={() => { + setConfirmTouched(true); }} ariaDescribedBy="invalidPassword" required={true} /> - {/* Feedback that appears for password match before submission */} - - {userToUpdate.password && - (!(userToUpdate.password == unverifiedPassword) - ? "Passwords don't match." - : !validatePassword(userToUpdate.password || "") && PASSWORD_REQUIREMENTS)} - + + {/* Feedback for password match */} + {showMatchError &&
Passwords do not match
} + + {(submissionAttempted || confirmTouched) && userToUpdate.password && passwordsMatch && !passwordIsValid && ( +
Please ensure your password meets all requirements
+ )} + + {submissionAttempted && (!userToUpdate.password || userToUpdate.password.length === 0) && ( +
Please confirm your password
+ )}
diff --git a/src/app/services/validation.ts b/src/app/services/validation.ts index eb5ea8920c..610a792c27 100644 --- a/src/app/services/validation.ts +++ b/src/app/services/validation.ts @@ -134,9 +134,7 @@ export const getPasswordValidationErrors = (password: string): string[] => { errors.push("Password must contain at least one uppercase letter"); } if (!/[!-/:-@[-`{-~]/.test(password)) { - errors.push( - "Password must contain at least one punctuation character (e.g., !@#$%^&*()-_=+[]{};:'\",.<>/?\\|`~)", - ); + errors.push("Password must contain at least one special character \n (e.g., !@#$%^&*()-_=+[]{};:'\",.<>/?\\|`~)"); } return errors; } else { diff --git a/src/test/pages/StudentRegistration.test.tsx b/src/test/pages/StudentRegistration.test.tsx index 742a01f0f5..2902b30e94 100644 --- a/src/test/pages/StudentRegistration.test.tsx +++ b/src/test/pages/StudentRegistration.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { StudentRegistration } from "../../app/components/pages/StudentRegistration"; import * as actions from "../../app/state/actions"; import { rest } from "msw"; -import { API_PATH, PASSWORD_REQUIREMENTS } from "../../app/services"; +import { API_PATH } from "../../app/services"; import { registrationMockUser, registrationUserData } from "../../mocks/data"; const registerUserSpy = jest.spyOn(actions, "registerUser"); @@ -82,7 +82,8 @@ describe("Student Registration", () => { const consentCheckbox = screen.getByRole("checkbox", { name: "Consent checkbox" }); await userEvent.click(consentCheckbox); await clickButton("Register my account"); - const pwErrorMessage = screen.getByText(PASSWORD_REQUIREMENTS); + // Check for password validation error messages (wrongPassword is "test1234" which lacks uppercase and special char) + const pwErrorMessage = screen.getByText(/Password requirements:/i); expect(pwErrorMessage).toBeVisible(); const generalError = screen.getByRole("heading", { name: /please fill out all fields/i, diff --git a/src/test/pages/TeacherRegistration.test.tsx b/src/test/pages/TeacherRegistration.test.tsx index 3263c4c63e..9caf205881 100644 --- a/src/test/pages/TeacherRegistration.test.tsx +++ b/src/test/pages/TeacherRegistration.test.tsx @@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event"; import { TeacherRegistration } from "../../app/components/pages/TeacherRegistration"; import * as actions from "../../app/state/actions"; import { rest } from "msw"; -import { API_PATH, PASSWORD_REQUIREMENTS } from "../../app/services"; +import { API_PATH } from "../../app/services"; import { registrationMockUser, registrationUserData } from "../../mocks/data"; const registerUserSpy = jest.spyOn(actions, "registerUser"); @@ -124,11 +124,12 @@ describe("Teacher Registration", () => { await fillFormCorrectly(false, "teacher"); const formFields = getFormFields(); const { password, confirmPassword, stage, noSchool, email } = formFields; - const pwErrorMessage = screen.getByText(PASSWORD_REQUIREMENTS); + // Check for password validation error messages (wrongPassword is "test1234" which lacks uppercase and special char) + const pwErrorMessage = screen.getByText(/Password requirements:/i); expect(pwErrorMessage).toBeVisible(); // update PW to meet requirements but not match the confirmation, and observe error changes await fillTextField(confirmPassword(), registrationUserData.password); - const newPwErrorMessage = screen.getByText(/Passwords don't match/i); + const newPwErrorMessage = screen.getByText(/Passwords do not match/i); expect(newPwErrorMessage).toBeVisible(); // update PW to meet requirements and try to submit, observe error messages for school, stage and email address await fillTextField(password(), registrationUserData.password);