]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18372 Display specific error message when failing password change
authorAmbroise C <ambroise.christea@sonarsource.com>
Wed, 1 Mar 2023 08:49:07 +0000 (09:49 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 1 Mar 2023 20:03:05 +0000 (20:03 +0000)
Also remove some code smells

server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx
server/sonar-web/src/main/js/apps/users/components/__tests__/PasswordForm-test.tsx
server/sonar-web/src/main/js/components/common/ResetPasswordForm.tsx
server/sonar-web/src/main/js/components/common/__tests__/ResetPasswordForm-test.tsx
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/ResetPasswordForm-test.tsx.snap
server/sonar-web/src/main/js/types/users.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index c4584533e7760e61f5aa4b47be3e7f7de88ab61c..ca921bd026c3a70b60cea3e458f695420eab72ba 100644 (file)
@@ -18,9 +18,9 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { throwGlobalError } from '../helpers/error';
-import { getJSON, post, postJSON } from '../helpers/request';
+import { getJSON, HttpStatus, parseJSON, post, postJSON } from '../helpers/request';
 import { IdentityProvider, Paging } from '../types/types';
-import { CurrentUser, HomePage, NoticeType, User } from '../types/users';
+import { ChangePasswordResults, CurrentUser, HomePage, NoticeType, User } from '../types/users';
 
 export function getCurrentUser(): Promise<CurrentUser> {
   return getJSON('/api/users/current', undefined, true);
@@ -35,7 +35,14 @@ export function changePassword(data: {
   password: string;
   previousPassword?: string;
 }) {
-  return post('/api/users/change_password', data).catch(throwGlobalError);
+  return post('/api/users/change_password', data).catch(async (response) => {
+    if (response.status === HttpStatus.BadRequest) {
+      const { result } = await parseJSON(response);
+      return Promise.reject<ChangePasswordResults>(result);
+    }
+
+    return throwGlobalError(response);
+  });
 }
 
 export interface UserGroup {
index 9cc62cb1d36418ac45602ebc55ae211d4ad6d5ab..a82a236f8b502ab22a9df07d4a5e904dad16bd1c 100644 (file)
@@ -24,11 +24,9 @@ import Modal from '../../../components/controls/Modal';
 import { Alert } from '../../../components/ui/Alert';
 import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
 import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
-import { throwGlobalError } from '../../../helpers/error';
 import { addGlobalSuccessMessage } from '../../../helpers/globalMessages';
 import { translate } from '../../../helpers/l10n';
-import { parseError } from '../../../helpers/request';
-import { User } from '../../../types/users';
+import { ChangePasswordResults, User } from '../../../types/users';
 
 interface Props {
   isCurrentUser: boolean;
@@ -38,7 +36,7 @@ interface Props {
 
 interface State {
   confirmPassword: string;
-  error?: string;
+  errorTranslationKey?: string;
   newPassword: string;
   oldPassword: string;
   submitting: boolean;
@@ -61,14 +59,13 @@ export default class PasswordForm extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  handleError = (response: Response) => {
-    if (!this.mounted || response.status !== 400) {
-      return throwGlobalError(response);
-    } else {
-      return parseError(response).then(
-        (errorMsg) => this.setState({ error: errorMsg, submitting: false }),
-        throwGlobalError
-      );
+  handleError = (result: ChangePasswordResults) => {
+    if (this.mounted) {
+      if (result === ChangePasswordResults.OldPasswordIncorrect) {
+        this.setState({ errorTranslationKey: 'user.old_password_incorrect', submitting: false });
+      } else if (result === ChangePasswordResults.NewPasswordSameAsOld) {
+        this.setState({ errorTranslationKey: 'user.new_password_same_as_old', submitting: false });
+      }
     }
   };
 
@@ -100,7 +97,7 @@ export default class PasswordForm extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { error, submitting, newPassword, confirmPassword } = this.state;
+    const { errorTranslationKey, submitting, newPassword, confirmPassword } = this.state;
 
     const header = translate('my_profile.password.title');
     return (
@@ -110,7 +107,7 @@ export default class PasswordForm extends React.PureComponent<Props, State> {
             <h2>{header}</h2>
           </header>
           <div className="modal-body">
-            {error && <Alert variant="error">{error}</Alert>}
+            {errorTranslationKey && <Alert variant="error">{translate(errorTranslationKey)}</Alert>}
 
             <MandatoryFieldsExplanation className="modal-field" />
 
index a1f1c68fa9ec93fdbab3dfe41ad0a611625f8d16..6b81c916a5e09433356ddf4daaf6838142c01c01 100644 (file)
@@ -21,6 +21,8 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import { changePassword } from '../../../../api/users';
 import { mockUser } from '../../../../helpers/testMocks';
+import { mockEvent, waitAndUpdate } from '../../../../helpers/testUtils';
+import { ChangePasswordResults } from '../../../../types/users';
 import PasswordForm from '../PasswordForm';
 
 const password = 'new password asdf';
@@ -38,38 +40,51 @@ it('should handle password change', async () => {
   const wrapper = shallowRender({ onClose });
 
   wrapper.setState({ newPassword: password, confirmPassword: password });
-  wrapper.instance().handleChangePassword({ preventDefault: jest.fn() } as any);
+  wrapper.instance().handleChangePassword(mockEvent({ preventDefault: jest.fn() }));
 
-  await new Promise(setImmediate);
+  await waitAndUpdate(wrapper);
 
   expect(onClose).toHaveBeenCalled();
 });
 
-it('should handle password change error', async () => {
+it('should handle password change error when new password is same as old', async () => {
   const wrapper = shallowRender();
 
-  (changePassword as jest.Mock).mockRejectedValue(new Response(undefined, { status: 400 }));
+  jest.mocked(changePassword).mockRejectedValue(ChangePasswordResults.NewPasswordSameAsOld);
+  wrapper.setState({ newPassword: password, confirmPassword: password });
+  wrapper.instance().mounted = true;
+  wrapper.instance().handleChangePassword(mockEvent({ preventDefault: jest.fn() }));
+
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.state().errorTranslationKey).toBe('user.new_password_same_as_old');
+});
+
+it('should handle password change error when old password is incorrect', async () => {
+  const wrapper = shallowRender();
+
+  jest.mocked(changePassword).mockRejectedValue(ChangePasswordResults.OldPasswordIncorrect);
 
   wrapper.setState({ newPassword: password, confirmPassword: password });
   wrapper.instance().mounted = true;
-  wrapper.instance().handleChangePassword({ preventDefault: jest.fn() } as any);
+  wrapper.instance().handleChangePassword(mockEvent({ preventDefault: jest.fn() }));
 
-  await new Promise(setImmediate);
+  await waitAndUpdate(wrapper);
 
-  expect(wrapper.state('error')).toBe('default_error_message');
+  expect(wrapper.state().errorTranslationKey).toBe('user.old_password_incorrect');
 });
 
 it('should handle form changes', () => {
   const wrapper = shallowRender();
 
-  wrapper.instance().handleConfirmPasswordChange({ currentTarget: { value: 'pwd' } } as any);
-  expect(wrapper.state('confirmPassword')).toBe('pwd');
+  wrapper.instance().handleConfirmPasswordChange(mockEvent({ currentTarget: { value: 'pwd' } }));
+  expect(wrapper.state().confirmPassword).toBe('pwd');
 
-  wrapper.instance().handleNewPasswordChange({ currentTarget: { value: 'pwd' } } as any);
-  expect(wrapper.state('newPassword')).toBe('pwd');
+  wrapper.instance().handleNewPasswordChange(mockEvent({ currentTarget: { value: 'pwd' } }));
+  expect(wrapper.state().newPassword).toBe('pwd');
 
-  wrapper.instance().handleOldPasswordChange({ currentTarget: { value: 'pwd' } } as any);
-  expect(wrapper.state('oldPassword')).toBe('pwd');
+  wrapper.instance().handleOldPasswordChange(mockEvent({ currentTarget: { value: 'pwd' } }));
+  expect(wrapper.state().oldPassword).toBe('pwd');
 });
 
 function shallowRender(props: Partial<PasswordForm['props']> = {}) {
index 129275f9f66cf9a4b16d56414673437661bdb4a0..b092ab0545e9d5325eea043e12355bd61d11ebac 100644 (file)
@@ -24,7 +24,7 @@ import { Alert } from '../../components/ui/Alert';
 import MandatoryFieldMarker from '../../components/ui/MandatoryFieldMarker';
 import MandatoryFieldsExplanation from '../../components/ui/MandatoryFieldsExplanation';
 import { translate } from '../../helpers/l10n';
-import { LoggedInUser } from '../../types/users';
+import { ChangePasswordResults, LoggedInUser } from '../../types/users';
 
 interface Props {
   className?: string;
@@ -76,12 +76,15 @@ export default class ResetPasswordForm extends React.Component<Props, State> {
       this.password.focus();
       this.setErrors([translate('user.password_doesnt_match_confirmation')]);
     } else {
-      changePassword({ login: user.login, password, previousPassword }).then(
-        this.handleSuccessfulChange,
-        () => {
-          // error already reported.
-        }
-      );
+      changePassword({ login: user.login, password, previousPassword })
+        .then(this.handleSuccessfulChange)
+        .catch((result: ChangePasswordResults) => {
+          if (result === ChangePasswordResults.OldPasswordIncorrect) {
+            this.setErrors([translate('user.old_password_incorrect')]);
+          } else if (result === ChangePasswordResults.NewPasswordSameAsOld) {
+            this.setErrors([translate('user.new_password_same_as_old')]);
+          }
+        });
     }
   };
 
@@ -94,9 +97,8 @@ export default class ResetPasswordForm extends React.Component<Props, State> {
         {success && <Alert variant="success">{translate('my_profile.password.changed')}</Alert>}
 
         {errors &&
-          errors.map((e, i) => (
-            /* eslint-disable-next-line react/no-array-index-key */
-            <Alert key={i} variant="error">
+          errors.map((e) => (
+            <Alert key={e} variant="error">
               {e}
             </Alert>
           ))}
index fedd4850bde3c9c94a91a3100a77d97b818cabe2..8e86f5a1d4afcc18a8000a65c7a59126ea05b0e1 100644 (file)
@@ -22,12 +22,18 @@ import * as React from 'react';
 import { changePassword } from '../../../api/users';
 import { mockLoggedInUser } from '../../../helpers/testMocks';
 import { mockEvent, waitAndUpdate } from '../../../helpers/testUtils';
+import { ChangePasswordResults } from '../../../types/users';
+import { Alert } from '../../ui/Alert';
 import ResetPasswordForm from '../ResetPasswordForm';
 
 jest.mock('../../../api/users', () => ({
   changePassword: jest.fn().mockResolvedValue({}),
 }));
 
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
 it('should trigger on password change prop', () => {
   const onPasswordChange = jest.fn();
   const wrapper = shallowRender({ onPasswordChange });
@@ -50,6 +56,30 @@ it('should not trigger password change', () => {
   expect(wrapper.state().errors).toBeDefined();
 });
 
+it('should not change password if new password is same as old', async () => {
+  jest.mocked(changePassword).mockRejectedValueOnce(ChangePasswordResults.NewPasswordSameAsOld);
+
+  const wrapper = shallowRender();
+  wrapper.instance().oldPassword = { value: 'testold' } as HTMLInputElement;
+  wrapper.instance().password = { value: 'test', focus: () => {} } as HTMLInputElement;
+  wrapper.instance().passwordConfirmation = { value: 'test' } as HTMLInputElement;
+  wrapper.instance().handleChangePassword(mockEvent());
+  await waitAndUpdate(wrapper);
+  expect(wrapper.find(Alert)).toMatchSnapshot();
+});
+
+it('should not change password if old password is incorrect', async () => {
+  jest.mocked(changePassword).mockRejectedValueOnce(ChangePasswordResults.OldPasswordIncorrect);
+
+  const wrapper = shallowRender();
+  wrapper.instance().oldPassword = { value: 'testold' } as HTMLInputElement;
+  wrapper.instance().password = { value: 'test', focus: () => {} } as HTMLInputElement;
+  wrapper.instance().passwordConfirmation = { value: 'test' } as HTMLInputElement;
+  wrapper.instance().handleChangePassword(mockEvent());
+  await waitAndUpdate(wrapper);
+  expect(wrapper.find(Alert)).toMatchSnapshot();
+});
+
 it('should trigger password change', async () => {
   const user = mockLoggedInUser();
   const wrapper = shallowRender({ user });
index 3f32833747d682c06b2de8777993f8bf249d1180..120f08f447fd8fb2b56db099db9f4003943cbaad 100644 (file)
@@ -69,3 +69,21 @@ exports[`renders correctly 1`] = `
   </div>
 </form>
 `;
+
+exports[`should not change password if new password is same as old 1`] = `
+<Alert
+  key="user.new_password_same_as_old"
+  variant="error"
+>
+  user.new_password_same_as_old
+</Alert>
+`;
+
+exports[`should not change password if old password is incorrect 1`] = `
+<Alert
+  key="user.old_password_incorrect"
+  variant="error"
+>
+  user.old_password_incorrect
+</Alert>
+`;
index f4e69a48f209b7ac296556f0a69d6c5c1f5fb3ad..eb94140e2b42c377e44cb5362e481832973fcd78 100644 (file)
@@ -89,6 +89,11 @@ export interface UserBase {
   name?: string;
 }
 
+export const enum ChangePasswordResults {
+  OldPasswordIncorrect = 'old_password_incorrect',
+  NewPasswordSameAsOld = 'new_password_same_as_old',
+}
+
 export function isUserActive(user: UserBase): user is UserActive {
   return user.active !== false && Boolean(user.name);
 }
index 62bb421d3a666b69882e1dc22a86ebfa7463da79..4e085db061aed6a97ce75960dd172a16ce3accd5 100644 (file)
@@ -2119,6 +2119,8 @@ alert.dismiss=Dismiss this message
 #
 #------------------------------------------------------------------------------
 user.password_doesnt_match_confirmation=Password doesn't match confirmation.
+user.old_password_incorrect=Old password is incorrect
+user.new_password_same_as_old=New password must be different from old password
 user.login_or_email_used_as_scm_account=Login and email are automatically considered as SCM accounts
 user.x_deleted={0} (deleted)