From a34335646d737b0ee07e573fe3668620ca26016f Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 8 Jan 2026 18:21:49 +0200 Subject: [PATCH 1/7] 761 - Replicate real-time password validation to sign-up form #761 --- .../inputs/RegistrationPasswordInputs.tsx | 118 +++++++++++++++--- 1 file changed, 100 insertions(+), 18 deletions(-) diff --git a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx index d2908a10f6..757dafe5c2 100644 --- a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx +++ b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx @@ -2,8 +2,19 @@ import React, { useState } from "react"; import { Col, FormFeedback, FormGroup, Label, Row, UncontrolledTooltip } from "reactstrap"; import { PasswordFeedback, ValidationUser } from "../../../../IsaacAppTypes"; import { Immutable } from "immer"; -import { PASSWORD_REQUIREMENTS, passwordDebounce, validatePassword } from "../../../services"; +import { + PASSWORD_REQUIREMENTS, + passwordDebounce, + validatePassword, + validatePasswordMatch, + getPasswordValidationErrors, + calculatePasswordStrength, + getPasswordStrengthLabel, + getPasswordStrengthColor, + PasswordStrength, +} from "../../../services"; import Password from "./Password"; + interface PasswordInputProps { userToUpdate: Immutable; setUserToUpdate: (user: Immutable) => void; @@ -23,8 +34,24 @@ export const RegistrationPasswordInputs = ({ }: PasswordInputProps) => { const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [passwordFeedback, setPasswordFeedback] = useState(null); + const [passwordTouched, setPasswordTouched] = useState(false); + const [confirmTouched, setConfirmTouched] = useState(false); + + // Calculate password strength and validation + const passwordStrength = unverifiedPassword ? calculatePasswordStrength(unverifiedPassword) : PasswordStrength.INVALID; + const passwordStrengthLabel = getPasswordStrengthLabel(passwordStrength); + const passwordStrengthColor = getPasswordStrengthColor(passwordStrength); + + 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 +69,58 @@ 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); + passwordDebounce(newValue, setPasswordFeedback); + // 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); + passwordDebounce(newValue, setPasswordFeedback); + setPasswordTouched(true); }} showToggleIcon={true} required={true} /> - {passwordFeedback && ( - - Password strength: - {passwordFeedback.feedbackText} - + + {/* Password strength indicator - always show if there's a password */} + {unverifiedPassword && unverifiedPassword.length > 0 && ( +
+ + Password strength: + + {passwordStrengthLabel} + + +
+ )} + + {/* Show validation errors */} + {showPasswordErrors && passwordErrors.length > 0 && ( +
+ Password requirements: +
    + {passwordErrors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} + + {/* If no password entered on submit */} + {submissionAttempted && (!unverifiedPassword || unverifiedPassword.length === 0) && ( +
+ Password is required +
)} @@ -70,20 +134,38 @@ 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 don't match +
+ )} + + {(submissionAttempted || confirmTouched) && userToUpdate.password && passwordsMatch && !passwordIsValid && ( +
+ Please ensure your password meets all requirements above +
+ )} + + {submissionAttempted && (!userToUpdate.password || userToUpdate.password.length === 0) && ( +
+ Please confirm your password +
+ )}
From ad279ed0b75242efbd2238d3a96f1d1af24c92ea Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 13 Jan 2026 09:59:02 +0200 Subject: [PATCH 2/7] Lint Fix --- .../inputs/RegistrationPasswordInputs.tsx | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx index 757dafe5c2..aab8347f78 100644 --- a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx +++ b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx @@ -1,10 +1,9 @@ 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, validatePasswordMatch, getPasswordValidationErrors, @@ -33,12 +32,13 @@ 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); // Calculate password strength and validation - const passwordStrength = unverifiedPassword ? calculatePasswordStrength(unverifiedPassword) : PasswordStrength.INVALID; + const passwordStrength = unverifiedPassword + ? calculatePasswordStrength(unverifiedPassword) + : PasswordStrength.INVALID; const passwordStrengthLabel = getPasswordStrengthLabel(passwordStrength); const passwordStrengthColor = getPasswordStrengthColor(passwordStrength); @@ -73,7 +73,6 @@ export const RegistrationPasswordInputs = ({ onChange={(e) => { const newValue = e.target.value; setUnverifiedPassword(newValue); - passwordDebounce(newValue, setPasswordFeedback); // Mark as touched when user starts typing if (newValue.length > 0) { setPasswordTouched(true); @@ -82,7 +81,6 @@ export const RegistrationPasswordInputs = ({ onBlur={(e) => { const newValue = e.target.value; setUnverifiedPassword(newValue); - passwordDebounce(newValue, setPasswordFeedback); setPasswordTouched(true); }} showToggleIcon={true} @@ -94,10 +92,7 @@ export const RegistrationPasswordInputs = ({
Password strength: - + {passwordStrengthLabel} @@ -108,7 +103,7 @@ export const RegistrationPasswordInputs = ({ {showPasswordErrors && passwordErrors.length > 0 && (
Password requirements: -
    +
      {passwordErrors.map((error, index) => (
    • {error}
    • ))} @@ -118,9 +113,7 @@ export const RegistrationPasswordInputs = ({ {/* If no password entered on submit */} {submissionAttempted && (!unverifiedPassword || unverifiedPassword.length === 0) && ( -
      - Password is required -
      +
      Password is required
      )} @@ -134,7 +127,7 @@ export const RegistrationPasswordInputs = ({ isPasswordVisible={isPasswordVisible} setIsPasswordVisible={setIsPasswordVisible} disabled={!unverifiedPassword} - invalid={(showMatchError || (submissionAttempted && !bothPasswordsValid)) ? true : undefined} + invalid={showMatchError || (submissionAttempted && !bothPasswordsValid) ? true : undefined} onChange={(e: React.ChangeEvent) => { setUserToUpdate({ ...userToUpdate, password: e.target.value }); if (e.target.value.length > 0) { @@ -149,11 +142,7 @@ export const RegistrationPasswordInputs = ({ /> {/* Feedback for password match */} - {showMatchError && ( -
      - Passwords don't match -
      - )} + {showMatchError &&
      Passwords do not match
      } {(submissionAttempted || confirmTouched) && userToUpdate.password && passwordsMatch && !passwordIsValid && (
      @@ -162,9 +151,7 @@ export const RegistrationPasswordInputs = ({ )} {submissionAttempted && (!userToUpdate.password || userToUpdate.password.length === 0) && ( -
      - Please confirm your password -
      +
      Please confirm your password
      )} From 5a7bbd277368bf7bad28b87a7f55f245f20d8282 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 13 Jan 2026 10:21:23 +0200 Subject: [PATCH 3/7] Sonar fix --- .../components/elements/inputs/RegistrationPasswordInputs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx index aab8347f78..c04d3b1d15 100644 --- a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx +++ b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx @@ -104,8 +104,8 @@ export const RegistrationPasswordInputs = ({
      Password requirements:
        - {passwordErrors.map((error, index) => ( -
      • {error}
      • + {passwordErrors.map((error) => ( +
      • {error}
      • ))}
      From 8e3ee48c874ab42c3a1e5e8e48989cdb41dde05f Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 13 Jan 2026 11:27:55 +0200 Subject: [PATCH 4/7] Fix --- .../inputs/RegistrationPasswordInputs.tsx | 40 +++++++------------ src/app/services/validation.ts | 4 +- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx index c04d3b1d15..2a9cb53b83 100644 --- a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx +++ b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx @@ -7,10 +7,6 @@ import { validatePassword, validatePasswordMatch, getPasswordValidationErrors, - calculatePasswordStrength, - getPasswordStrengthLabel, - getPasswordStrengthColor, - PasswordStrength, } from "../../../services"; import Password from "./Password"; @@ -35,13 +31,6 @@ export const RegistrationPasswordInputs = ({ const [passwordTouched, setPasswordTouched] = useState(false); const [confirmTouched, setConfirmTouched] = useState(false); - // Calculate password strength and validation - const passwordStrength = unverifiedPassword - ? calculatePasswordStrength(unverifiedPassword) - : PasswordStrength.INVALID; - const passwordStrengthLabel = getPasswordStrengthLabel(passwordStrength); - const passwordStrengthColor = getPasswordStrengthColor(passwordStrength); - const passwordIsValid = validatePassword(unverifiedPassword || ""); const passwordsMatch = validatePasswordMatch(unverifiedPassword || "", userToUpdate.password || ""); const bothPasswordsValid = passwordIsValid && passwordsMatch; @@ -87,26 +76,25 @@ export const RegistrationPasswordInputs = ({ required={true} /> - {/* Password strength indicator - always show if there's a password */} - {unverifiedPassword && unverifiedPassword.length > 0 && ( -
      - - Password strength: - - {passwordStrengthLabel} - - -
      - )} - {/* Show validation errors */} {showPasswordErrors && passwordErrors.length > 0 && (
      Password requirements:
        - {passwordErrors.map((error) => ( -
      • {error}
      • - ))} + {passwordErrors.map((error) => { + // Split error message for special character requirement to add line break + const specialCharMatch = error.match(/^(.*special character)\s+(\(e\.g\.,.*\))$/); + if (specialCharMatch) { + return ( +
      • + {specialCharMatch[1]} +
        + {specialCharMatch[2]} +
      • + ); + } + return
      • {error}
      • ; + })}
      )} diff --git a/src/app/services/validation.ts b/src/app/services/validation.ts index eb5ea8920c..e8618666ba 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 (e.g., !@#$%^&*()-_=+[]{};:'\",.<>/?\\|`~)"); } return errors; } else { From e03f6b768f9cd288a529f6ad326bf1db3a09f269 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 13 Jan 2026 11:46:39 +0200 Subject: [PATCH 5/7] Fix --- src/test/pages/StudentRegistration.test.tsx | 5 +++-- src/test/pages/TeacherRegistration.test.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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); From 8a18eef59ea0f51292a5276468d78581d2d86284 Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 13 Jan 2026 12:50:35 +0200 Subject: [PATCH 6/7] Fix --- .../inputs/RegistrationPasswordInputs.tsx | 27 +++++++++---------- src/app/services/validation.ts | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx index 2a9cb53b83..0c674ebb08 100644 --- a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx +++ b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx @@ -82,18 +82,17 @@ export const RegistrationPasswordInputs = ({ Password requirements:
        {passwordErrors.map((error) => { - // Split error message for special character requirement to add line break - const specialCharMatch = error.match(/^(.*special character)\s+(\(e\.g\.,.*\))$/); - if (specialCharMatch) { - return ( -
      • - {specialCharMatch[1]} -
        - {specialCharMatch[2]} -
      • - ); - } - return
      • {error}
      • ; + const parts = error.split(/\n/); + return ( +
      • + {parts.map((line, i) => ( + + {i > 0 &&
        } + {line} +
        + ))} +
      • + ); })}
      @@ -133,9 +132,7 @@ export const RegistrationPasswordInputs = ({ {showMatchError &&
      Passwords do not match
      } {(submissionAttempted || confirmTouched) && userToUpdate.password && passwordsMatch && !passwordIsValid && ( -
      - Please ensure your password meets all requirements above -
      +
      Please ensure your password meets all requirements
      )} {submissionAttempted && (!userToUpdate.password || userToUpdate.password.length === 0) && ( diff --git a/src/app/services/validation.ts b/src/app/services/validation.ts index e8618666ba..610a792c27 100644 --- a/src/app/services/validation.ts +++ b/src/app/services/validation.ts @@ -134,7 +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 special character (e.g., !@#$%^&*()-_=+[]{};:'\",.<>/?\\|`~)"); + errors.push("Password must contain at least one special character \n (e.g., !@#$%^&*()-_=+[]{};:'\",.<>/?\\|`~)"); } return errors; } else { From 0bb2111b101cb9c6f3eae6fa2d82904cdc249a6a Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 13 Jan 2026 12:58:05 +0200 Subject: [PATCH 7/7] Fix --- .../components/elements/inputs/RegistrationPasswordInputs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx index 0c674ebb08..6518a79e9e 100644 --- a/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx +++ b/src/app/components/elements/inputs/RegistrationPasswordInputs.tsx @@ -86,7 +86,7 @@ export const RegistrationPasswordInputs = ({ return (
    • {parts.map((line, i) => ( - + {i > 0 &&
      } {line}