-
Notifications
You must be signed in to change notification settings - Fork 326
O3-1409: Support for users changing passwords #902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,32 @@ | ||||||||
| import React, { useCallback } from "react"; | ||||||||
| import { useTranslation } from "react-i18next"; | ||||||||
| import { Button, Switcher, SwitcherDivider } from "@carbon/react"; | ||||||||
| import { navigate } from "@openmrs/esm-framework"; | ||||||||
| import styles from "./change-password-button.scss"; | ||||||||
|
|
||||||||
| export interface ChangePasswordLinkProps {} | ||||||||
|
|
||||||||
| const ChangePasswordLink: React.FC<ChangePasswordLinkProps> = () => { | ||||||||
| const { t } = useTranslation(); | ||||||||
| const goToChangePassword = useCallback(() => { | ||||||||
| navigate({ to: "${openmrsSpaBase}/change-password"}); | ||||||||
| }, []); | ||||||||
|
|
||||||||
| return ( | ||||||||
| <> | ||||||||
| <SwitcherDivider className={styles.divider} /> | ||||||||
| <Switcher aria-label="Switcher Container"> | ||||||||
| <Button | ||||||||
| className={styles.userProfileButton} | ||||||||
| onClick={goToChangePassword} | ||||||||
| aria-labelledby="changePassword" | ||||||||
| role="button" | ||||||||
| > | ||||||||
| {t("changePassword", "Change Password")} | ||||||||
| </Button> | ||||||||
| </Switcher> | ||||||||
| </> | ||||||||
| ); | ||||||||
| }; | ||||||||
|
|
||||||||
| export default ChangePasswordLink; | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| @import "../root.scss"; | ||
|
|
||
| .userProfileButton { | ||
| padding-right: 0rem; | ||
| @include brand-02(background-color); | ||
| @extend .productiveHeading01; | ||
| width: 16rem; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using the Carbon |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import React from 'react'; | ||
| import ChangePasswordButton from './change-password-button.component'; | ||
| import { render, screen } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { navigate } from '@openmrs/esm-framework'; | ||
|
|
||
| const navigateMock = navigate as jest.Mock; | ||
|
|
||
| delete window.location; | ||
| window.location = new URL('https://dev3.openmrs.org/openmrs/spa/home') as any as Location; | ||
|
|
||
| describe('<ChangePasswordButton/>', () => { | ||
| beforeEach(() => { | ||
| render(<ChangePasswordButton />); | ||
| }); | ||
|
|
||
| it('should display the `Change Password` button', async () => { | ||
| const user = userEvent.setup(); | ||
| const changePasswordButton = await screen.findByRole('button', { | ||
| name: /Change Password/i, | ||
| }); | ||
|
|
||
| await user.click(changePasswordButton); | ||
|
|
||
| expect(navigateMock).toHaveBeenCalledWith({ | ||
| to: '${openmrsSpaBase}/change-password', | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| import classNames from 'classnames'; | ||
| import React, { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import styles from './change-passwords.scss'; | ||
| import { useTranslation } from 'react-i18next'; | ||
| import { InlineNotification, PasswordInput, Tile, Button } from '@carbon/react'; | ||
| import { navigate, ExtensionSlot, setUserLanguage, useConfig, showToast } from '@openmrs/esm-framework'; | ||
| import { ButtonSet } from '@carbon/react'; | ||
| import { performPasswordChange } from './change-password.resource'; | ||
| import { performLogout } from '../redirect-logout/logout.resource'; | ||
|
|
||
| export interface ChangePasswordProps {} | ||
|
|
||
| const ChangePassword: React.FC<ChangePasswordProps> = () => { | ||
| const { t } = useTranslation(); | ||
| const config = useConfig(); | ||
| const [errorMessage, setErrorMessage] = useState(''); | ||
| const [isSavingPassword, setIsSavingPassword] = useState(false); | ||
| const oldPasswordInputRef = useRef<HTMLInputElement>(null); | ||
| const newPasswordInputRef = useRef<HTMLInputElement>(null); | ||
| const confirmPasswordInputRef = useRef<HTMLInputElement>(null); | ||
| const formRef = useRef<HTMLFormElement>(null); | ||
| const [newPasswordError, setNewPasswordErr] = useState(''); | ||
| const [oldPasswordError, setOldPasswordErr] = useState(''); | ||
| const [confirmPasswordError, setConfirmPasswordError] = useState(''); | ||
| const [isOldPasswordInvalid, setIsOldPasswordInvalid] = useState<boolean>(true); | ||
| const [isNewPasswordInvalid, setIsNewPasswordInvalid] = useState<boolean>(true); | ||
| const [isConfirmPasswordInvalid, setIsConfirmPasswordInvalid] = useState<boolean>(true); | ||
|
|
||
| const [passwordInput, setPasswordInput] = useState({ | ||
| oldPassword: '', | ||
| newPassword: '', | ||
| confirmPassword: '', | ||
| }); | ||
|
|
||
| const resetUserNameAndPassword = useCallback(() => { | ||
| setPasswordInput({ oldPassword: '', newPassword: '', confirmPassword: '' }); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| if (passwordInput.oldPassword !== '') { | ||
| handleValidation(passwordInput.oldPassword, 'oldPassword'); | ||
| } | ||
| if (passwordInput.newPassword !== '') { | ||
| handleValidation(passwordInput.newPassword, 'newPassword'); | ||
| } | ||
| if (passwordInput.confirmPassword !== '') { | ||
| handleValidation(passwordInput.confirmPassword, 'confirmPassword'); | ||
| } | ||
| }, [passwordInput]); | ||
|
|
||
| const handlePasswordChange = (event) => { | ||
| const passwordInputValue = event.target.value.trim(); | ||
| const passwordInputFieldName = event.target.name; | ||
| const NewPasswordInput = { ...passwordInput, [passwordInputFieldName]: passwordInputValue }; | ||
| setPasswordInput(NewPasswordInput); | ||
| }; | ||
|
|
||
| const handleValidation = (passwordInputValue, passwordInputFieldName) => { | ||
| if (passwordInputFieldName === 'newPassword') { | ||
| const uppercaseRegExp = /(?=.*?[A-Z])/; | ||
| const lowercaseRegExp = /(?=.*?[a-z])/; | ||
| const digitsRegExp = /(?=.*?[0-9])/; | ||
| const minLengthRegExp = /.{8,}/; | ||
| const passwordLength = passwordInputValue.length; | ||
| const uppercasePassword = uppercaseRegExp.test(passwordInputValue); | ||
| const lowercasePassword = lowercaseRegExp.test(passwordInputValue); | ||
| const digitsPassword = digitsRegExp.test(passwordInputValue); | ||
| const minLengthPassword = minLengthRegExp.test(passwordInputValue); | ||
| let errMsg = ''; | ||
| if (passwordLength === 0) { | ||
| errMsg = 'Password is empty'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any display string always needs to be translatable. |
||
| } else if (!uppercasePassword) { | ||
| errMsg = 'At least one Uppercase'; | ||
| } else if (!lowercasePassword) { | ||
| errMsg = 'At least one Lowercase'; | ||
| } else if (!digitsPassword) { | ||
| errMsg = 'At least one digit'; | ||
| } else if (!minLengthPassword) { | ||
| errMsg = 'At least minimum 8 characters'; | ||
| } else if (passwordInput.oldPassword.length > 0 && passwordInput.newPassword === passwordInput.oldPassword) { | ||
| errMsg = 'New password must not be the same as the old password'; | ||
| } else { | ||
| errMsg = ''; | ||
| setIsNewPasswordInvalid(false); | ||
| } | ||
| setNewPasswordErr(errMsg); | ||
| } else if ( | ||
| passwordInputFieldName === 'confirmPassword' || | ||
| (passwordInputFieldName === 'newPassword' && passwordInput.confirmPassword.length > 0) | ||
| ) { | ||
| if (passwordInput.confirmPassword !== passwordInput.newPassword) { | ||
| setConfirmPasswordError('Confirm password is must be the same as the new password'); | ||
| } else { | ||
| setConfirmPasswordError(''); | ||
| setIsConfirmPasswordInvalid(false); | ||
| } | ||
| } else { | ||
| if (passwordInput.newPassword.length > 0 && passwordInput.newPassword === passwordInput.oldPassword) { | ||
| setOldPasswordErr('Old password must not be the same as the new password'); | ||
| } else { | ||
| setOldPasswordErr(''); | ||
| setIsOldPasswordInvalid(false); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const handleSubmit = useCallback( | ||
| async (evt: React.FormEvent<HTMLFormElement>) => { | ||
| evt.preventDefault(); | ||
| evt.stopPropagation(); | ||
|
|
||
| try { | ||
| setIsSavingPassword(true); | ||
| const response = await performPasswordChange(passwordInput.oldPassword, passwordInput.confirmPassword); | ||
| if (response.ok) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| performLogout().then(() => { | ||
| const defaultLang = document.documentElement.getAttribute('data-default-lang'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Uh... just redirect to the logout handler I guess... We've never forced people to re-login after changing their password though, so I'm not a huge fan of this. |
||
| setUserLanguage({ | ||
| locale: defaultLang, | ||
| authenticated: false, | ||
| sessionId: '', | ||
| }); | ||
| if (config.provider.type === 'oauth2') { | ||
| navigate({ to: config.provider.logoutUrl }); | ||
| } else { | ||
| navigate({ to: '${openmrsSpaBase}/login' }); | ||
| } | ||
| showToast({ | ||
| title: t('userPassword', 'User password'), | ||
| description: t('userPasswordUpdated', 'User password updated successfully'), | ||
| kind: 'success', | ||
| }); | ||
| }); | ||
| } else { | ||
| throw new Error('invalidPasswordCredentials'); | ||
| } | ||
| } catch (error) { | ||
| setIsSavingPassword(false); | ||
| setErrorMessage(error.message); | ||
| } | ||
|
|
||
| return false; | ||
| }, | ||
|
|
||
| [passwordInput, resetUserNameAndPassword], | ||
| ); | ||
| return ( | ||
| <> | ||
| <ExtensionSlot name="breadcrumbs-slot" /> | ||
| <div className={classNames('canvas', styles['container'])}> | ||
| <div className={styles['input-group']}> | ||
| {errorMessage && ( | ||
| <InlineNotification | ||
| className={styles.errorMessage} | ||
| kind="error" | ||
| /** | ||
| * This comment tells i18n to still keep the following translation keys (used as value for: errorMessage): | ||
| * t('invalidPasswordCredentials') | ||
| */ | ||
| subtitle={t(errorMessage)} | ||
| title={t('error', 'Error')} | ||
| onClick={() => setErrorMessage('')} | ||
| /> | ||
| )} | ||
| <Tile className={styles['change-password-card']}> | ||
| <form onSubmit={handleSubmit} ref={formRef}> | ||
| <div className={styles['input-group']}> | ||
| <PasswordInput | ||
| id="oldPassword" | ||
| invalid={oldPasswordError.length > 0} | ||
| invalidText={oldPasswordError} | ||
| labelText={t('oldPassword', 'Old Password')} | ||
| name="oldPassword" | ||
| value={passwordInput.oldPassword} | ||
| onChange={handlePasswordChange} | ||
| ref={oldPasswordInputRef} | ||
| required | ||
| showPasswordLabel="Show old password" | ||
| /> | ||
| <PasswordInput | ||
| id="newPassword" | ||
| invalid={newPasswordError.length > 0} | ||
| invalidText={newPasswordError} | ||
| labelText={t('newPassword', 'New Password')} | ||
| name="newPassword" | ||
| value={passwordInput.newPassword} | ||
| onChange={handlePasswordChange} | ||
| ref={newPasswordInputRef} | ||
| required | ||
| showPasswordLabel="Show new password" | ||
| /> | ||
| <PasswordInput | ||
| id="confirmPassword" | ||
| invalid={confirmPasswordError.length > 0} | ||
| invalidText={confirmPasswordError} | ||
| labelText={t('confirmPassword', 'Confirm Password')} | ||
| name="confirmPassword" | ||
| value={passwordInput.confirmPassword} | ||
| onChange={handlePasswordChange} | ||
| ref={confirmPasswordInputRef} | ||
| required | ||
| showPasswordLabel="Show confirm password" | ||
| /> | ||
| <ButtonSet className={styles.buttonSet}> | ||
| <Button | ||
| style={{ maxWidth: '50%' }} | ||
| onClick={() => navigate({ to: `\${openmrsSpaBase}/home` })} | ||
| disabled={isSavingPassword} | ||
| kind="secondary" | ||
| > | ||
| {t('discard', 'Discard')} | ||
| </Button> | ||
| <Button | ||
| style={{ maxWidth: '50%' }} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd use SCSS rather than inline styles for this. |
||
| disabled={ | ||
| isSavingPassword || isNewPasswordInvalid || isConfirmPasswordInvalid || isOldPasswordInvalid | ||
| } | ||
| kind="primary" | ||
| type="submit" | ||
| > | ||
| {t('save', 'Save')} | ||
| </Button> | ||
| </ButtonSet> | ||
| </div> | ||
| </form> | ||
| </Tile> | ||
| </div> | ||
| </div> | ||
| </> | ||
| ); | ||
| }; | ||
| export default ChangePassword; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { openmrsFetch } from '@openmrs/esm-framework'; | ||
|
|
||
| export async function performPasswordChange(oldPassword: string, newPassword: string) { | ||
| const abortController = new AbortController(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not much use for an AbortController inside this method. This is what |
||
|
|
||
| return openmrsFetch(`/ws/rest/v1/password`, { | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| method: 'POST', | ||
| body: { | ||
| "oldPassword": oldPassword, | ||
| "newPassword": newPassword, | ||
| }, | ||
| signal: abortController.signal, | ||
| }).then((res) => { | ||
| return res; | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that the
SwitcherDividershould be part of this class.