diff --git a/src/actions.js b/src/actions.js index 6d3e325..a7f0928 100644 --- a/src/actions.js +++ b/src/actions.js @@ -330,6 +330,13 @@ export function fetchUsernameLength() { return graphql(payload, `USERNAME_LENGTH_FIELDS`); } +export function fetchPasswordPolicy() { + const payload = `query { + passwordPolicy + }`; + return graphql(payload, "PASSWORD_POLICY_FIELDS"); +} + export function usernameValidationClear() { return (dispatch) => { dispatch({ type: `USERNAME_FIELDS_VALIDATION_CLEAR` }); diff --git a/src/components/UserForm.js b/src/components/UserForm.js index a313c64..53d2146 100644 --- a/src/components/UserForm.js +++ b/src/components/UserForm.js @@ -30,6 +30,7 @@ import { fetchObligatoryUserFields, fetchObligatoryEnrolmentOfficerFields, fetchUsernameLength, + fetchPasswordPolicy, } from "../actions"; import UserMasterPanel from "./UserMasterPanel"; @@ -69,6 +70,9 @@ class UserForm extends Component { if (!this.state.usernameLength) { this.props.fetchUsernameLength(); } + if (!this.state.passwordPolicy) { + this.props.fetchPasswordPolicy(); + } } componentWillUnmount() { @@ -212,6 +216,7 @@ class UserForm extends Component { obligatoryUserFields, obligatoryEoFields, usernameLength, + passwordPolicy } = this.props; const { user, isSaved, reset } = this.state; @@ -255,6 +260,7 @@ class UserForm extends Component { obligatory_user_fields={obligatoryUserFields} obligatory_eo_fields={obligatoryEoFields} usernameLength={usernameLength} + passwordPolicy={passwordPolicy} /> )} @@ -277,6 +283,7 @@ const mapStateToProps = (state) => ({ isUserNameValid: state.admin.validationFields?.username?.isValid, isUserEmailValid: state.admin.validationFields?.userEmail?.isValid, usernameLength: state.admin?.usernameLength, + passwordPolicy: state.admin?.passwordPolicy, isUserEmailFormatInvalid: state.admin.validationFields?.userEmailFormat?.isInvalid, }); @@ -291,6 +298,7 @@ const mapDispatchToProps = (dispatch) => fetchObligatoryUserFields, fetchObligatoryEnrolmentOfficerFields, fetchUsernameLength, + fetchPasswordPolicy, journalize, coreConfirm, }, diff --git a/src/components/UserMasterPanel.js b/src/components/UserMasterPanel.js index 2d2fef8..eda630a 100644 --- a/src/components/UserMasterPanel.js +++ b/src/components/UserMasterPanel.js @@ -23,9 +23,11 @@ import { userEmailValidationClear, setUserEmailValid, saveEmailFormatValidity, + fetchPasswordPolicy, } from "../actions"; import { passwordGenerator } from "../helpers/passwordGenerator"; +import { validatePassword } from "../helpers/passwordValidator"; const styles = (theme) => ({ tableTitle: theme.table.title, @@ -59,9 +61,15 @@ const UserMasterPanel = (props) => { savedUsername, savedUserEmail, usernameLength, + passwordPolicy, } = props; - const { formatMessage } = useTranslations("admin", modulesManager); + const { formatMessage, formatMessageWithValues } = useTranslations("admin", modulesManager); const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchPasswordPolicy()); + }, [dispatch]); + const renderLastNameFirst = modulesManager.getConf( "fe-insuree", "renderLastNameFirst", @@ -97,13 +105,23 @@ const UserMasterPanel = (props) => { handleEmailChange(edited?.email); }, []); + const [passwordFeedback, setPasswordFeedback] = useState(""); + const [passwordScore, setPasswordScore] = useState(0); const [showPassword, setShowPassword] = useState(false); + const IS_PASSWORD_SECURED = passwordScore >= 2; const handleClickShowPassword = () => setShowPassword((show) => !show); const handleMouseDownPassword = (event) => { event.preventDefault(); }; + const handlePasswordChange = (password) => { + const { feedback, score } = validatePassword(password, passwordPolicy, formatMessage, formatMessageWithValues); + setPasswordFeedback(feedback); + setPasswordScore(score); + onEditedChanged({ ...edited, password }); + }; + const generatePassword = () => { const passwordGeneratorOptions = modulesManager.getConf("fe-admin", "passwordGeneratorOptions", { length: 10, @@ -181,47 +199,47 @@ const UserMasterPanel = (props) => { obligatoryUserFields?.email == "H" || (edited.userTypes?.includes(ENROLMENT_OFFICER_USER_TYPE) && obligatoryEOFields?.email == "H") ) && ( - - handleEmailChange(email)} - /> - - )} + + handleEmailChange(email)} + /> + + )} {!( obligatoryUserFields?.phone == "H" || (edited.userTypes?.includes(ENROLMENT_OFFICER_USER_TYPE) && obligatoryEOFields?.phone == "H") ) && ( - - onEditedChanged({ ...edited, phoneNumber })} - /> - - )} + + onEditedChanged({ ...edited, phoneNumber })} + /> + + )} { label="user.newPassword" readOnly={readOnly} value={edited.password} - onChange={(password) => onEditedChanged({ ...edited, password })} + onChange={(password) => { + handlePasswordChange(password); + }} endAdornment={ { } /> + + {passwordFeedback} + { + if (condition) { + suggestions.push(message); + } +}; + +export const generateFeedback = (suggestions, formatMessageWithValues) => { + if (suggestions.length > 0) { + const requirements = suggestions.join(', '); + const formattedMessage = formatMessageWithValues("admin.password.requirements", { requirements }); + return { feedback: formattedMessage, score: 0 }; + } + return null; +}; + +export const validatePassword = (password, passwordPolicy, formatMessage, formatMessageWithValues) => { + if (!passwordPolicy || !password) { + return { feedback: "", score: 0 }; + } + + const jsonPasswordPolicy = JSON.parse(passwordPolicy); + const suggestions = []; + + const { + min_length, + require_lower_case, + require_upper_case, + require_numbers, + require_special_characters + } = jsonPasswordPolicy || {}; + + addSuggestion(suggestions, password.length < min_length, formatMessageWithValues("admin.password.minLength", { count: min_length })); + addSuggestion(suggestions, require_lower_case && !/[a-z]/.test(password), formatMessageWithValues("admin.password.lowerCase", { count: require_lower_case })); + addSuggestion(suggestions, require_upper_case && !/[A-Z]/.test(password), formatMessageWithValues("admin.password.upperCase", { count: require_upper_case })); + addSuggestion(suggestions, require_numbers && !/[0-9]/.test(password), formatMessageWithValues("admin.password.numbers", { count: require_numbers })); + addSuggestion(suggestions, require_special_characters && !/[^a-zA-Z0-9]/.test(password), formatMessageWithValues("admin.password.specialCharacters", { count: require_special_characters })); + + const feedbackResult = generateFeedback(suggestions, formatMessageWithValues); + if (feedbackResult) { + return feedbackResult; + } + + const result = zxcvbn(password); + const feedback = result.feedback.suggestions.join(" ") || formatMessage("admin.password.strong"); + const score = result.score; + + let scoreDescription; + switch (score) { + case 1: + scoreDescription = `${formatMessage("admin.password.weak")}. ${feedback}`; + break; + case 2: + scoreDescription = `${formatMessage("admin.password.medium")}. ${feedback}`; + break; + case 3: + scoreDescription = `${formatMessage("admin.password.strong")}`; + break; + case 4: + scoreDescription = `${formatMessage("admin.password.veryStrong")}`; + break; + default: + scoreDescription = formatMessage("admin.password.unknownScore"); + } + + return { + feedback: scoreDescription, + score, + }; +}; diff --git a/src/reducer.js b/src/reducer.js index 26dc93f..47e5c39 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -477,6 +477,28 @@ function reducer( fetchingUsernameLength: false, errorUsernameLength: formatServerError(action.payload), }; + case "PASSWORD_POLICY_FIELDS_REQ": + return { + ...state, + fetchingPasswordPolicy: true, + fetchedPasswordPolicy: false, + passwordPolicy: null, + errorPasswordPolicy: null, + }; + case "PASSWORD_POLICY_FIELDS_RESP": + return { + ...state, + fetchingPasswordPolicy: false, + fetchedPasswordPolicy: true, + passwordPolicy: action.payload.data.passwordPolicy, + errorPasswordPolicy: formatGraphQLError(action.payload), + }; + case "PASSWORD_POLICY_FIELDS_ERR": + return { + ...state, + fetchingPasswordPolicy: false, + errorPasswordPolicy: formatServerError(action.payload), + }; case "ADMIN_USER_MUTATION_REQ": return dispatchMutationReq(state, action); case "ADMIN_USER_MUTATION_ERR": diff --git a/src/translations/en.json b/src/translations/en.json index 4aaf670..705be09 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -82,5 +82,16 @@ "admin.EnrolmentOfficerPicker.openText": "Open", "admin.EnrolmentOfficerPicker.closeText": "Close", "admin.UserFilter.showHistory": "Show History", - "admin.UserFilter.showDeleted": "Show Deleted" + "admin.UserFilter.showDeleted": "Show Deleted", + "admin.password.minLength": "at least {count} characters", + "admin.password.lowerCase": "{count, plural, one {# lowercase letter} other {# lowercase letters}}", + "admin.password.upperCase": "{count, plural, one {# uppercase letter} other {# uppercase letters}}", + "admin.password.numbers": "{count, plural, one {# number} other {# numbers}}", + "admin.password.specialCharacters": "{count, plural, one {# special character} other {# special characters}}", + "admin.password.requirements": "Password must include: {requirements}.", + "admin.password.weak": "Weak", + "admin.password.medium": "Medium", + "admin.password.strong": "Password is strong.", + "admin.password.veryStrong": "Password is very strong.", + "admin.password.unknownScore": "Unknown score." }