Skip to content

Commit

Permalink
Merge pull request #104 from openimis/feature/CQI-149
Browse files Browse the repository at this point in the history
Feature/cqi 149: password validator for user form creation
  • Loading branch information
olewandowski1 authored May 22, 2024
2 parents 473e150 + 9ae53fc commit ad1897d
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 40 deletions.
7 changes: 7 additions & 0 deletions src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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` });
Expand Down
8 changes: 8 additions & 0 deletions src/components/UserForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
fetchObligatoryUserFields,
fetchObligatoryEnrolmentOfficerFields,
fetchUsernameLength,
fetchPasswordPolicy,
} from "../actions";
import UserMasterPanel from "./UserMasterPanel";

Expand Down Expand Up @@ -69,6 +70,9 @@ class UserForm extends Component {
if (!this.state.usernameLength) {
this.props.fetchUsernameLength();
}
if (!this.state.passwordPolicy) {
this.props.fetchPasswordPolicy();
}
}

componentWillUnmount() {
Expand Down Expand Up @@ -212,6 +216,7 @@ class UserForm extends Component {
obligatoryUserFields,
obligatoryEoFields,
usernameLength,
passwordPolicy
} = this.props;
const { user, isSaved, reset } = this.state;

Expand Down Expand Up @@ -255,6 +260,7 @@ class UserForm extends Component {
obligatory_user_fields={obligatoryUserFields}
obligatory_eo_fields={obligatoryEoFields}
usernameLength={usernameLength}
passwordPolicy={passwordPolicy}
/>
)}
</div>
Expand All @@ -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,
});

Expand All @@ -291,6 +298,7 @@ const mapDispatchToProps = (dispatch) =>
fetchObligatoryUserFields,
fetchObligatoryEnrolmentOfficerFields,
fetchUsernameLength,
fetchPasswordPolicy,
journalize,
coreConfirm,
},
Expand Down
101 changes: 62 additions & 39 deletions src/components/UserMasterPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -181,47 +199,47 @@ const UserMasterPanel = (props) => {
obligatoryUserFields?.email == "H" ||
(edited.userTypes?.includes(ENROLMENT_OFFICER_USER_TYPE) && obligatoryEOFields?.email == "H")
) && (
<Grid item xs={4} className={classes.item}>
<ValidatedTextInput
itemQueryIdentifier="userEmail"
shouldValidate={shouldValidateEmail}
isValid={isUserEmailValid}
isValidating={isUserEmailValidating}
validationError={emailValidationError}
invalidValueFormat={isUserEmailFormatInvalid}
action={userEmailValidationCheck}
clearAction={userEmailValidationClear}
setValidAction={setUserEmailValid}
readOnly={readOnly}
module="admin"
label="user.email"
type="email"
codeTakenLabel="user.emailAlreadyTaken"
required={true}
value={edited?.email ?? ""}
onChange={(email) => handleEmailChange(email)}
/>
</Grid>
)}
<Grid item xs={4} className={classes.item}>
<ValidatedTextInput
itemQueryIdentifier="userEmail"
shouldValidate={shouldValidateEmail}
isValid={isUserEmailValid}
isValidating={isUserEmailValidating}
validationError={emailValidationError}
invalidValueFormat={isUserEmailFormatInvalid}
action={userEmailValidationCheck}
clearAction={userEmailValidationClear}
setValidAction={setUserEmailValid}
readOnly={readOnly}
module="admin"
label="user.email"
type="email"
codeTakenLabel="user.emailAlreadyTaken"
required={true}
value={edited?.email ?? ""}
onChange={(email) => handleEmailChange(email)}
/>
</Grid>
)}
{!(
obligatoryUserFields?.phone == "H" ||
(edited.userTypes?.includes(ENROLMENT_OFFICER_USER_TYPE) && obligatoryEOFields?.phone == "H")
) && (
<Grid item xs={4} className={classes.item}>
<TextInput
module="admin"
type="phone"
label="user.phone"
required={
obligatoryUserFields?.phone == "M" ||
(edited.userTypes?.includes(ENROLMENT_OFFICER_USER_TYPE) && obligatoryEOFields?.phone == "M")
}
readOnly={readOnly}
value={edited?.phoneNumber ?? ""}
onChange={(phoneNumber) => onEditedChanged({ ...edited, phoneNumber })}
/>
</Grid>
)}
<Grid item xs={4} className={classes.item}>
<TextInput
module="admin"
type="phone"
label="user.phone"
required={
obligatoryUserFields?.phone == "M" ||
(edited.userTypes?.includes(ENROLMENT_OFFICER_USER_TYPE) && obligatoryEOFields?.phone == "M")
}
readOnly={readOnly}
value={edited?.phoneNumber ?? ""}
onChange={(phoneNumber) => onEditedChanged({ ...edited, phoneNumber })}
/>
</Grid>
)}
<Grid item xs={4} className={classes.item}>
<PublishedComponent
pubRef="location.HealthFacilityPicker"
Expand Down Expand Up @@ -295,7 +313,9 @@ const UserMasterPanel = (props) => {
label="user.newPassword"
readOnly={readOnly}
value={edited.password}
onChange={(password) => onEditedChanged({ ...edited, password })}
onChange={(password) => {
handlePasswordChange(password);
}}
endAdornment={
<InputAdornment position="end">
<IconButton
Expand All @@ -309,6 +329,9 @@ const UserMasterPanel = (props) => {
</InputAdornment>
}
/>
<Typography color={IS_PASSWORD_SECURED ? "primary" : "error"} className={classes.passwordFeedback}>
{passwordFeedback}
</Typography>
</Grid>
<Grid item xs={4} className={classes.item}>
<TextInput
Expand Down
71 changes: 71 additions & 0 deletions src/helpers/passwordValidator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import zxcvbn from 'zxcvbn';

export const addSuggestion = (suggestions, condition, message) => {
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,
};
};
22 changes: 22 additions & 0 deletions src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
13 changes: 12 additions & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}

0 comments on commit ad1897d

Please sign in to comment.