Skip to content

Commit

Permalink
(feat) O3-3423: Allow user to change password (#1047)
Browse files Browse the repository at this point in the history
Co-authored-by: gitcliff <[email protected]>
  • Loading branch information
ibacher and gitcliff authored Jul 2, 2024
1 parent ecd896c commit 01a0b40
Show file tree
Hide file tree
Showing 17 changed files with 327 additions and 16 deletions.
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,10 @@
"@carbon/icons-react": "11.37.0",
"minipass": "3.3.5"
},
"packageManager": "[email protected]"
"packageManager": "[email protected]",
"dependencies": {
"@hookform/resolvers": "^3.6.0",
"react-hook-form": "^7.52.0",
"zod": "^3.23.8"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const navigateMock = navigate as jest.Mock;
const useSessionMock = useSession as jest.Mock;

delete window.location;
window.location = new URL('https://dev3.openmrs.org/openmrs/spa/home') as any as Location;
window.location = new URL('https://dev3.openmrs.org/openmrs/spa/home') as unknown as Location;

describe('<ChangeLocationLink/>', () => {
beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, SwitcherItem } from '@carbon/react';
import { PasswordIcon, showModal } from '@openmrs/esm-framework';
import styles from './change-password.scss';

const ChangePasswordLink: React.FC = () => {
const { t } = useTranslation();

const launchChangePasswordModal = useCallback(() => showModal('change-password-modal'), []);

return (
<SwitcherItem aria-label={t('changePassword', 'ChangePassword')} className={styles.panelItemContainer}>
<div>
<PasswordIcon size={20} />
<p>{t('password', 'Password')}</p>
</div>
<Button kind="ghost" onClick={launchChangePasswordModal}>
{t('change', 'Change')}
</Button>
</SwitcherItem>
);
};

export default ChangePasswordLink;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import { showModal } from '@openmrs/esm-framework';
import ChangePasswordLink from './change-password-link.extension';

const mockShowModal = jest.mocked(showModal);

describe('<ChangePasswordLink/>', () => {
it('should display the `Change password` link', async () => {
const user = userEvent.setup();

render(<ChangePasswordLink />);

const changePasswordLink = screen.getByRole('button', {
name: /Change/i,
});

await user.click(changePasswordLink);

expect(mockShowModal).toHaveBeenCalledTimes(1);
expect(mockShowModal).toHaveBeenCalledWith('change-password-modal');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.submitButton {
:global(.cds--inline-loading) {
min-height: 1rem;
}

:global(.cds--inline-loading__text) {
font-size: unset;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, FormProvider, useForm, type SubmitHandler } from 'react-hook-form';
import { Button, Form, PasswordInput, InlineLoading, ModalBody, ModalFooter, ModalHeader, Stack } from '@carbon/react';
import { showSnackbar } from '@openmrs/esm-framework';
import { changeUserPassword } from './change-password.resource';
import styles from './change-password-modal.scss';

interface ChangePasswordModalProps {
close(): () => void;
}

const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ close }) => {
const { t } = useTranslation();
const [isChangingPassword, setIsChangingPassword] = useState(false);

const oldPasswordValidation = z.string({
required_error: t('oldPasswordRequired', 'Old password is required'),
});

const newPasswordValidation = z.string({
required_error: t('newPasswordRequired', 'New password is required'),
});

const passwordConfirmationValidation = z.string({
required_error: t('passwordConfirmationRequired', 'Password confirmation is required'),
});

const changePasswordFormSchema = z
.object({
oldPassword: oldPasswordValidation,
newPassword: newPasswordValidation,
passwordConfirmation: passwordConfirmationValidation,
})
.refine((data) => data.newPassword === data.passwordConfirmation, {
message: t('passwordsDoNotMatch', 'Passwords do not match'),
path: ['passwordConfirmation'],
});

const methods = useForm<z.infer<typeof changePasswordFormSchema>>({
mode: 'all',
resolver: zodResolver(changePasswordFormSchema),
});

const onSubmit: SubmitHandler<z.infer<typeof changePasswordFormSchema>> = useCallback((data) => {
setIsChangingPassword(true);

const { oldPassword, newPassword } = data;

changeUserPassword(oldPassword, newPassword)
.then(() => {
close();

showSnackbar({
title: t('passwordChangedSuccessfully', 'Password changed successfully'),
kind: 'success',
});
})
.catch((error) => {
showSnackbar({
kind: 'error',
subtitle: error?.message,
title: t('errorChangingPassword', 'Error changing password'),
});
})
.finally(() => {
setIsChangingPassword(false);
});
}, []);

const onError = () => setIsChangingPassword(false);

return (
<FormProvider {...methods}>
<Form onSubmit={methods.handleSubmit(onSubmit, onError)}>
<ModalHeader closeModal={close} title={t('changePassword', 'Change password')} />
<ModalBody>
<Stack gap={5} className={styles.languageOptionsContainer}>
<Controller
name="oldPassword"
control={methods.control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<PasswordInput
id="oldPassword"
invalid={!!error}
invalidText={error?.message}
labelText={t('oldPassword', 'Old password')}
onChange={onChange}
value={value}
/>
)}
/>
<Controller
name="newPassword"
control={methods.control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<PasswordInput
id="newPassword"
invalid={!!error}
invalidText={error?.message}
labelText={t('newPassword', 'New password')}
onChange={onChange}
value={value}
/>
)}
/>
<Controller
name="passwordConfirmation"
control={methods.control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<PasswordInput
id="passwordConfirmation"
invalid={!!error}
invalidText={error?.message}
labelText={t('confirmPassword', 'Confirm new password')}
onChange={onChange}
value={value}
/>
)}
/>
</Stack>
</ModalBody>
<ModalFooter>
<Button kind="secondary" onClick={close}>
{t('cancel', 'Cancel')}
</Button>
<Button className={styles.submitButton} disabled={isChangingPassword} type="submit">
{isChangingPassword ? (
<InlineLoading description={t('changingLanguage', 'Changing password') + '...'} />
) : (
<span>{t('change', 'Change')}</span>
)}
</Button>
</ModalFooter>
</Form>
</FormProvider>
);
};

export default ChangePasswordModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';

export function changeUserPassword(oldPassword: string, newPassword: string) {
return openmrsFetch(`${restBaseUrl}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: {
oldPassword,
newPassword,
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.alignCenter {
display: flex;
text-align: center;
}

.panelItemContainer {
a {
@extend .alignCenter;
justify-content: space-between;
}

div {
@extend .alignCenter;
}
}
9 changes: 6 additions & 3 deletions packages/apps/esm-login-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { defineConfigSchema, getSyncLifecycle } from '@openmrs/esm-framework';
import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework';
import { configSchema } from './config-schema';
import rootComponent from './root.component';
import locationPickerComponent from './location-picker/location-picker.component';
import changeLocationLinkComponent from './change-location-link/change-location-link.extension';
import changePasswordLinkComponent from './change-password/change-password-link.extension';
import locationPickerComponent from './location-picker/location-picker.component';
import logoutButtonComponent from './logout/logout.extension';
import rootComponent from './root.component';

const moduleName = '@openmrs/esm-login-app';

Expand All @@ -22,3 +23,5 @@ export const root = getSyncLifecycle(rootComponent, options);
export const locationPicker = getSyncLifecycle(locationPickerComponent, options);
export const logoutButton = getSyncLifecycle(logoutButtonComponent, options);
export const changeLocationLink = getSyncLifecycle(changeLocationLinkComponent, options);
export const changePasswordLink = getSyncLifecycle(changePasswordLinkComponent, options);
export const changePasswordModal = getAsyncLifecycle(() => import('./change-password/change-password.modal'), options);
13 changes: 13 additions & 0 deletions packages/apps/esm-login-app/src/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
"online": true,
"offline": true
},
{
"name": "password-changer",
"slot": "user-panel-slot",
"component": "changePasswordLink",
"online": true,
"offline": true
},
{
"name": "location-changer",
"slot": "user-panel-slot",
Expand All @@ -40,5 +47,11 @@
"offline": true,
"order": 1
}
],
"modals": [
{
"name": "change-password-modal",
"component": "changePasswordModal"
}
]
}
12 changes: 12 additions & 0 deletions packages/apps/esm-login-app/translations/en.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
{
"back": "Back",
"backToUserNameIconLabel": "Back to username",
"cancel": "Cancel",
"change": "Change",
"changePassword": "Change password",
"changingLanguage": "Changing password",
"confirmPassword": "Confirm new password",
"continue": "Continue",
"errorChangingPassword": "Error changing password",
"errorLoadingLoginLocations": "Error loading login locations",
"invalidCredentials": "Invalid username or password",
"loading": "Loading",
Expand All @@ -15,9 +20,16 @@
"loggingIn": "Logging in",
"login": "Log in",
"Logout": "Logout",
"newPassword": "New password",
"newPasswordRequired": "New password is required",
"noResultsToDisplay": "No results to display",
"oldPassword": "Old password",
"oldPasswordRequired": "Old password is required",
"openmrsLogo": "OpenMRS logo",
"password": "Password",
"passwordChangedSuccessfully": "Password changed successfully",
"passwordConfirmationRequired": "Password confirmation is required",
"passwordsDoNotMatch": "Passwords do not match",
"poweredBy": "Powered by",
"rememberLocationForFutureLogins": "Remember my location for future logins",
"searchForLocation": "Search for a location",
Expand Down
Loading

2 comments on commit 01a0b40

@denniskigen
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ibacher looks like the minified React error causing the E2E test failure is related to the icons package.

@ibacher
Copy link
Member Author

@ibacher ibacher commented on 01a0b40 Jul 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's an on-going issue. The E2E tests never have the latest framework changes until they are merged in and the icon we use was added in this PR.

Please sign in to comment.