@ampath/esm-login-app 8.0.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/.editorconfig +8 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc +76 -0
  4. package/.gitattributes +4 -0
  5. package/.prettierignore +9 -0
  6. package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
  7. package/.yarn/versions/643d2b70.yml +21 -0
  8. package/README.md +4 -0
  9. package/__mocks__/config.mock.ts +26 -0
  10. package/__mocks__/locations.mock.ts +540 -0
  11. package/__mocks__/react-i18next.js +49 -0
  12. package/dist/1128.js +1 -0
  13. package/dist/1128.js.map +1 -0
  14. package/dist/1480.js +1 -0
  15. package/dist/1578.js +1 -0
  16. package/dist/1578.js.map +1 -0
  17. package/dist/1646.js +1 -0
  18. package/dist/1800.js +1 -0
  19. package/dist/1800.js.map +1 -0
  20. package/dist/1869.js +1 -0
  21. package/dist/1877.js +1 -0
  22. package/dist/2317.js +1 -0
  23. package/dist/2416.js +1 -0
  24. package/dist/2489.js +1 -0
  25. package/dist/2489.js.map +1 -0
  26. package/dist/282.js +1 -0
  27. package/dist/2881.js +1 -0
  28. package/dist/2997.js +1 -0
  29. package/dist/2997.js.map +1 -0
  30. package/dist/3219.js +1 -0
  31. package/dist/3219.js.map +1 -0
  32. package/dist/3378.js +1 -0
  33. package/dist/3963.js +1 -0
  34. package/dist/4106.js +1 -0
  35. package/dist/4111.js +1 -0
  36. package/dist/4169.js +1 -0
  37. package/dist/4169.js.map +1 -0
  38. package/dist/434.js +1 -0
  39. package/dist/4348.js +1 -0
  40. package/dist/4378.js +1 -0
  41. package/dist/4378.js.map +1 -0
  42. package/dist/4383.js +1 -0
  43. package/dist/4658.js +1 -0
  44. package/dist/4668.js +1 -0
  45. package/dist/4668.js.map +1 -0
  46. package/dist/4870.js +1 -0
  47. package/dist/4870.js.map +1 -0
  48. package/dist/4928.js +1 -0
  49. package/dist/4bc56e5b0b0e91da.png +0 -0
  50. package/dist/5098.js +1 -0
  51. package/dist/5098.js.map +1 -0
  52. package/dist/5117.js +1 -0
  53. package/dist/5132.js +1 -0
  54. package/dist/5145.js +1 -0
  55. package/dist/5503.js +1 -0
  56. package/dist/556.js +1 -0
  57. package/dist/5644.js +1 -0
  58. package/dist/5898.js +1 -0
  59. package/dist/5898.js.map +1 -0
  60. package/dist/5940.js +1 -0
  61. package/dist/5976.js +1 -0
  62. package/dist/5976.js.map +1 -0
  63. package/dist/6047.js +1 -0
  64. package/dist/6237.js +1 -0
  65. package/dist/6237.js.map +1 -0
  66. package/dist/6362.js +1 -0
  67. package/dist/6362.js.map +1 -0
  68. package/dist/6371.js +1 -0
  69. package/dist/6377.js +1 -0
  70. package/dist/6444.js +1 -0
  71. package/dist/647e55b5cedf5df2.png +0 -0
  72. package/dist/6508.js +1 -0
  73. package/dist/6724.js +1 -0
  74. package/dist/6904.js +1 -0
  75. package/dist/7045.js +1 -0
  76. package/dist/7144.js +43 -0
  77. package/dist/7144.js.map +1 -0
  78. package/dist/7175.js +1 -0
  79. package/dist/7182.js +1 -0
  80. package/dist/7251.js +1 -0
  81. package/dist/7251.js.map +1 -0
  82. package/dist/749.js +1 -0
  83. package/dist/749.js.map +1 -0
  84. package/dist/7742.js +1 -0
  85. package/dist/7912.js +1 -0
  86. package/dist/8358.js +1 -0
  87. package/dist/8359.js +1 -0
  88. package/dist/8695.js +1 -0
  89. package/dist/903.js +1 -0
  90. package/dist/9072.js +1 -0
  91. package/dist/9510.js +15 -0
  92. package/dist/9510.js.map +1 -0
  93. package/dist/9806.js +1 -0
  94. package/dist/a6792134b9df70c4.png +0 -0
  95. package/dist/acd6ab71c5f6bcb6.jpg +0 -0
  96. package/dist/d0bf081185f017f3.jpg +0 -0
  97. package/dist/d48e253df6a333a7.png +0 -0
  98. package/dist/esm-login-app.js +6 -0
  99. package/dist/esm-login-app.js.buildmanifest.json +1598 -0
  100. package/dist/esm-login-app.js.map +1 -0
  101. package/dist/main.js +6 -0
  102. package/dist/main.js.map +1 -0
  103. package/dist/routes.json +1 -0
  104. package/jest.config.js +20 -0
  105. package/package.json +111 -0
  106. package/prettier.config.js +8 -0
  107. package/rspack.config.js +1 -0
  108. package/src/assets/Taifa-Care.png +0 -0
  109. package/src/assets/ampath-logo.png +0 -0
  110. package/src/assets/dha.png +0 -0
  111. package/src/assets/gok.png +0 -0
  112. package/src/assets/medicine.jpg +0 -0
  113. package/src/assets/openmrs.jpg +0 -0
  114. package/src/change-location-link/change-location-link.extension.tsx +32 -0
  115. package/src/change-location-link/change-location-link.scss +17 -0
  116. package/src/change-location-link/change-location-link.test.tsx +36 -0
  117. package/src/change-password/change-password-link.extension.tsx +30 -0
  118. package/src/change-password/change-password-link.test.tsx +27 -0
  119. package/src/change-password/change-password-modal.scss +11 -0
  120. package/src/change-password/change-password.component.tsx +159 -0
  121. package/src/change-password/change-password.modal.tsx +175 -0
  122. package/src/change-password/change-password.resource.ts +12 -0
  123. package/src/change-password/change-password.scss +51 -0
  124. package/src/change-password/change-password.test.tsx +53 -0
  125. package/src/common/otp/otp.component.tsx +54 -0
  126. package/src/common/otp/otp.scss +13 -0
  127. package/src/common/resend-timer/resend-timer.component.tsx +56 -0
  128. package/src/common/resend-timer/resend-timer.scss +7 -0
  129. package/src/config-schema.ts +140 -0
  130. package/src/declarations.d.ts +5 -0
  131. package/src/footer.component.tsx +60 -0
  132. package/src/footer.scss +113 -0
  133. package/src/index.ts +27 -0
  134. package/src/loading/loading.component.tsx +11 -0
  135. package/src/loading/loading.scss +7 -0
  136. package/src/location-picker/location-picker-view.component.tsx +174 -0
  137. package/src/location-picker/location-picker.resource.ts +111 -0
  138. package/src/location-picker/location-picker.scss +94 -0
  139. package/src/location-picker/location-picker.test.tsx +341 -0
  140. package/src/login/login.component.tsx +350 -0
  141. package/src/login/login.scss +246 -0
  142. package/src/login/login.test.tsx +288 -0
  143. package/src/login.resource.ts +147 -0
  144. package/src/logo.component.tsx +22 -0
  145. package/src/logout/logout.extension.tsx +23 -0
  146. package/src/logout/logout.scss +12 -0
  147. package/src/otp/otp.component.tsx +108 -0
  148. package/src/otp/otp.module.scss +44 -0
  149. package/src/redirect-logout/logout.resource.ts +15 -0
  150. package/src/redirect-logout/redirect-logout.component.tsx +42 -0
  151. package/src/redirect-logout/redirect-logout.test.tsx +180 -0
  152. package/src/resources/otp.resource.ts +90 -0
  153. package/src/root.component.tsx +24 -0
  154. package/src/routes.json +63 -0
  155. package/src/setupTests.ts +15 -0
  156. package/src/test-helpers/render-with-router.tsx +17 -0
  157. package/src/types.ts +34 -0
  158. package/src/utils/get-base-url.ts +17 -0
  159. package/translations/am.json +41 -0
  160. package/translations/ar.json +41 -0
  161. package/translations/ar_SY.json +41 -0
  162. package/translations/bn.json +41 -0
  163. package/translations/cs.json +41 -0
  164. package/translations/de.json +41 -0
  165. package/translations/en.json +41 -0
  166. package/translations/en_US.json +41 -0
  167. package/translations/es.json +41 -0
  168. package/translations/es_MX.json +41 -0
  169. package/translations/fr.json +41 -0
  170. package/translations/he.json +41 -0
  171. package/translations/hi.json +41 -0
  172. package/translations/hi_IN.json +41 -0
  173. package/translations/id.json +41 -0
  174. package/translations/it.json +41 -0
  175. package/translations/ka.json +41 -0
  176. package/translations/km.json +41 -0
  177. package/translations/ku.json +41 -0
  178. package/translations/ky.json +41 -0
  179. package/translations/lg.json +41 -0
  180. package/translations/ne.json +41 -0
  181. package/translations/pl.json +41 -0
  182. package/translations/pt.json +41 -0
  183. package/translations/pt_BR.json +41 -0
  184. package/translations/qu.json +41 -0
  185. package/translations/ro_RO.json +41 -0
  186. package/translations/ru_RU.json +41 -0
  187. package/translations/si.json +41 -0
  188. package/translations/sq.json +41 -0
  189. package/translations/sw.json +41 -0
  190. package/translations/sw_KE.json +41 -0
  191. package/translations/tr.json +41 -0
  192. package/translations/tr_TR.json +41 -0
  193. package/translations/uk.json +41 -0
  194. package/translations/uz.json +41 -0
  195. package/translations/uz@Latn.json +41 -0
  196. package/translations/uz_UZ.json +41 -0
  197. package/translations/vi.json +41 -0
  198. package/translations/zh.json +41 -0
  199. package/translations/zh_CN.json +41 -0
  200. package/translations/zh_TW.json +41 -0
  201. package/tsconfig.json +25 -0
  202. package/yarnrc.yml +14 -0
@@ -0,0 +1 @@
1
+ {"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"webservices.rest":">=2.2.0"},"pages":[{"component":"root","route":"login","online":true,"offline":true},{"component":"root","route":"logout","online":true,"offline":true},{"component":"root","route":"change-password","online":true,"offline":true}],"extensions":[{"name":"location-picker","slot":"location-picker","component":"locationPicker","online":true,"offline":true},{"name":"logout-button","slot":"user-panel-bottom-slot","component":"logoutButton","online":true,"offline":true},{"name":"password-changer","slot":"user-panel-slot","component":"changePasswordLink","online":true,"offline":true},{"name":"location-changer","slot":"top-nav-info-slot","component":"changeLocationLink","online":true,"offline":true,"order":1}],"modals":[{"name":"change-password-modal","component":"changePasswordModal"}],"version":"8.0.0-next.10"}
package/jest.config.js ADDED
@@ -0,0 +1,20 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ clearMocks: true,
4
+ transform: {
5
+ '^.+\\.[jt]sx?$': ['@swc/jest'],
6
+ },
7
+ moduleNameMapper: {
8
+ '@openmrs/esm-framework': '@openmrs/esm-framework/mock',
9
+ '\\.(s?css)$': 'identity-obj-proxy',
10
+ '^lodash-es/(.*)$': 'lodash/$1',
11
+ 'lodash-es': 'lodash',
12
+ dexie: require.resolve('dexie'),
13
+ },
14
+ setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
15
+ testEnvironment: 'jsdom',
16
+ testEnvironmentOptions: {
17
+ url: 'http://localhost/',
18
+ },
19
+ transformIgnorePatterns: ['/node_modules/', '\\.pnp\\.[^\\/]+$'],
20
+ };
package/package.json ADDED
@@ -0,0 +1,111 @@
1
+ {
2
+ "name": "@ampath/esm-login-app",
3
+ "version": "8.0.0-next.10",
4
+ "license": "MPL-2.0",
5
+ "description": "The login microfrontend for the OpenMRS SPA",
6
+ "browser": "dist/esm-login-app.js",
7
+ "main": "src/index.ts",
8
+ "source": true,
9
+ "scripts": {
10
+ "start": "openmrs develop",
11
+ "dev": "openmrs develop --backend=https://staging.ampath.or.ke --api-url=/openmrs --config-url=https://ngx.ampath.or.ke/o3-config.json --port=9000",
12
+ "serve": "rspack serve --mode=development",
13
+ "debug": "npm run serve",
14
+ "test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests --color",
15
+ "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color",
16
+ "build": "rspack --mode=production",
17
+ "build:development": "rspack --mode=development",
18
+ "analyze": "rspack --mode=production --env analyze=true",
19
+ "typescript": "tsc",
20
+ "lint": "eslint src --ext ts,tsx",
21
+ "extract-translations": "i18next 'src/**/*.component.tsx' 'src/**/*.extension.tsx' 'src/**/*.modal.tsx' 'src/**/*.resource.ts' --config='../../../tools/i18next-parser.config.js'",
22
+ "verify": "turbo lint typescript"
23
+ },
24
+ "keywords": [
25
+ "openmrs",
26
+ "microfrontends"
27
+ ],
28
+ "browserslist": [
29
+ "extends browserslist-config-openmrs"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/ampath/ampath-esm-login-app.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/ampath/ampath-esm-login-app/issues"
37
+ },
38
+ "homepage": "https://github.com/ampath/ampath-esm-login-app#readme",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@carbon/react": "^1.92.1",
44
+ "@hookform/resolvers": "^3.6.0",
45
+ "lodash-es": "^4.17.21",
46
+ "openmrs": "next",
47
+ "react-hook-form": "^7.52.2",
48
+ "zod": "^3.23.8"
49
+ },
50
+ "peerDependencies": {
51
+ "@carbon/react": "1.x",
52
+ "@openmrs/esm-framework": "8.x",
53
+ "react": "18.x",
54
+ "react-dom": "18.x",
55
+ "react-i18next": "16.x",
56
+ "react-router-dom": "6.x",
57
+ "rxjs": "6.x",
58
+ "swr": "2.x"
59
+ },
60
+ "devDependencies": {
61
+ "@openmrs/esm-framework": "next",
62
+ "@openmrs/webpack-config": "next",
63
+ "@playwright/test": "^1.50.1",
64
+ "@rspack/cli": "^1.3.11",
65
+ "@rspack/core": "^1.3.11",
66
+ "@swc/core": "^1.11.29",
67
+ "@swc/jest": "^0.2.38",
68
+ "@testing-library/dom": "^10.4.1",
69
+ "@testing-library/jest-dom": "^6.8.0",
70
+ "@testing-library/react": "^16.3.0",
71
+ "@testing-library/user-event": "^14.6.1",
72
+ "@types/jest": "^29.5.12",
73
+ "@types/react": "^18.3.21",
74
+ "@types/react-dom": "^18.3.0",
75
+ "@types/webpack-env": "^1.16.4",
76
+ "@typescript-eslint/eslint-plugin": "^8.35.0",
77
+ "@typescript-eslint/parser": "^8.35.0",
78
+ "classnames": "^2.3.2",
79
+ "cross-env": "^7.0.3",
80
+ "dotenv": "^16.0.3",
81
+ "eslint": "^8.55.0",
82
+ "eslint-plugin-import": "^2.31.0",
83
+ "eslint-plugin-jest-dom": "^5.5.0",
84
+ "eslint-plugin-playwright": "^2.1.0",
85
+ "eslint-plugin-react-hooks": "^4.6.2",
86
+ "eslint-plugin-testing-library": "^7.1.1",
87
+ "fake-indexeddb": "^4.0.2",
88
+ "fork-ts-checker-webpack-plugin": "^7.2.13",
89
+ "husky": "^8.0.3",
90
+ "i18next-parser": "^9.3.0",
91
+ "identity-obj-proxy": "^3.0.0",
92
+ "jest": "^29.7.0",
93
+ "jest-cli": "^29.7.0",
94
+ "jest-environment-jsdom": "^29.7.0",
95
+ "lint-staged": "^15.2.9",
96
+ "prettier": "^3.1.0",
97
+ "react": "^18.3.1",
98
+ "react-dom": "^18.3.1",
99
+ "react-i18next": "^16.0.0",
100
+ "react-router-dom": "^6.3.0",
101
+ "rxjs": "^6.5.3",
102
+ "swc-loader": "^0.2.6",
103
+ "swr": "2.2.5",
104
+ "timezone-mock": "^1.2.2",
105
+ "turbo": "^2.5.2",
106
+ "typescript": "^5.8.3",
107
+ "webpack": "^5.99.9"
108
+ },
109
+ "packageManager": "yarn@4.10.3",
110
+ "stableVersion": "8.0.0"
111
+ }
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ bracketSpacing: true,
3
+ printWidth: 120,
4
+ semi: true,
5
+ singleQuote: true,
6
+ tabWidth: 2,
7
+ trailingComma: "all",
8
+ };
@@ -0,0 +1 @@
1
+ module.exports = require('openmrs/default-rspack-config');
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { HeaderGlobalAction } from '@carbon/react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { LocationIcon, navigate, useSession } from '@openmrs/esm-framework';
5
+ import styles from './change-location-link.scss';
6
+
7
+ const ChangeLocationLink: React.FC = () => {
8
+ const { t } = useTranslation();
9
+ const session = useSession();
10
+ const currentLocation = session?.sessionLocation?.display;
11
+
12
+ const changeLocation = () => {
13
+ // update=true is passed as a query param for updating the location preference,
14
+ // The location picker won't redirect with default location on finding the update=true param.
15
+ navigate({
16
+ to: `\${openmrsSpaBase}/login/location?returnToUrl=${window.location.pathname}&update=true`,
17
+ });
18
+ };
19
+
20
+ return (
21
+ <HeaderGlobalAction
22
+ aria-label={t('changeLocation', 'Change location')}
23
+ className={styles.changeLocationButton}
24
+ onClick={changeLocation}
25
+ >
26
+ <LocationIcon size={16} />
27
+ <span className={styles.currentLocationText}>{currentLocation}</span>
28
+ </HeaderGlobalAction>
29
+ );
30
+ };
31
+
32
+ export default ChangeLocationLink;
@@ -0,0 +1,17 @@
1
+ @use '@carbon/layout';
2
+
3
+ .changeLocationButton {
4
+ width: fit-content;
5
+ background-color: transparent;
6
+ color: white;
7
+ font-size: 14px;
8
+ padding: layout.$spacing-04 !important; // this gets unset in rtl language without !important
9
+
10
+ &:hover {
11
+ color: white;
12
+ }
13
+ }
14
+
15
+ .currentLocationText {
16
+ padding-inline: layout.$spacing-04;
17
+ }
@@ -0,0 +1,36 @@
1
+ import React from 'react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { navigate, type Session, useSession } from '@openmrs/esm-framework';
5
+ import ChangeLocationLink from './change-location-link.extension';
6
+
7
+ const mockNavigate = jest.mocked(navigate);
8
+ const mockUseSession = jest.mocked(useSession);
9
+
10
+ delete window.location;
11
+ (window.location as Location) = new URL('https://dev3.openmrs.org/openmrs/spa/home') as unknown as Location;
12
+
13
+ describe('ChangeLocationLink', () => {
14
+ beforeEach(() => {
15
+ mockUseSession.mockReturnValue({
16
+ sessionLocation: {
17
+ display: 'Waffle House',
18
+ },
19
+ } as Session);
20
+ });
21
+
22
+ it('should display the `Change location` link', async () => {
23
+ render(<ChangeLocationLink />);
24
+
25
+ const user = userEvent.setup();
26
+ const changeLocationButton = await screen.findByRole('button', {
27
+ name: /Change/i,
28
+ });
29
+
30
+ await user.click(changeLocationButton);
31
+
32
+ expect(mockNavigate).toHaveBeenCalledWith({
33
+ to: '${openmrsSpaBase}/login/location?returnToUrl=/openmrs/spa/home&update=true',
34
+ });
35
+ });
36
+ });
@@ -0,0 +1,30 @@
1
+ import React, { useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button, SwitcherItem } from '@carbon/react';
4
+ import { PasswordIcon, showModal } from '@openmrs/esm-framework';
5
+ import styles from './change-password.scss';
6
+
7
+ const ChangePasswordLink: React.FC = () => {
8
+ const { t } = useTranslation();
9
+
10
+ const launchChangePasswordModal = useCallback(() => {
11
+ const dispose = showModal('change-password-modal', {
12
+ closeModal: () => dispose(),
13
+ size: 'sm',
14
+ });
15
+ }, []);
16
+
17
+ return (
18
+ <SwitcherItem aria-label={t('changePassword', 'ChangePassword')} className={styles.panelItemContainer}>
19
+ <div>
20
+ <PasswordIcon size={20} />
21
+ <p>{t('password', 'Password')}</p>
22
+ </div>
23
+ <Button kind="ghost" onClick={launchChangePasswordModal}>
24
+ {t('change', 'Change')}
25
+ </Button>
26
+ </SwitcherItem>
27
+ );
28
+ };
29
+
30
+ export default ChangePasswordLink;
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { showModal } from '@openmrs/esm-framework';
5
+ import ChangePasswordLink from './change-password-link.extension';
6
+
7
+ const mockShowModal = jest.mocked(showModal);
8
+
9
+ describe('ChangePasswordLink', () => {
10
+ it('should launch the change password modal', async () => {
11
+ const user = userEvent.setup();
12
+
13
+ render(<ChangePasswordLink />);
14
+
15
+ const changePasswordLink = screen.getByRole('button', {
16
+ name: /Change/i,
17
+ });
18
+
19
+ await user.click(changePasswordLink);
20
+
21
+ expect(mockShowModal).toHaveBeenCalledTimes(1);
22
+ expect(mockShowModal).toHaveBeenCalledWith('change-password-modal', {
23
+ size: 'sm',
24
+ closeModal: expect.any(Function),
25
+ });
26
+ });
27
+ });
@@ -0,0 +1,11 @@
1
+ @use '@carbon/layout';
2
+
3
+ .submitButton {
4
+ :global(.cds--inline-loading) {
5
+ min-height: layout.$spacing-05;
6
+ }
7
+
8
+ :global(.cds--inline-loading__text) {
9
+ font-size: unset;
10
+ }
11
+ }
@@ -0,0 +1,159 @@
1
+ import React, { useCallback, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { z } from 'zod';
4
+ import { zodResolver } from '@hookform/resolvers/zod';
5
+ import { Controller, FieldError, useForm, type SubmitHandler } from 'react-hook-form';
6
+ import { Button, Form, PasswordInput, InlineLoading, Tile } from '@carbon/react';
7
+ import { showSnackbar } from '@openmrs/esm-framework';
8
+ import { changeUserPassword } from './change-password.resource';
9
+ import Logo from '../logo.component';
10
+ import styles from './change-password.scss';
11
+
12
+ const ChangePassword: React.FC = () => {
13
+ const { t } = useTranslation();
14
+ const [isChangingPassword, setIsChangingPassword] = useState(false);
15
+
16
+ const oldPasswordValidation = z.string({
17
+ required_error: t('oldPasswordRequired', 'Old password is required'),
18
+ });
19
+
20
+ const newPasswordValidation = z.string({
21
+ required_error: t('newPasswordRequired', 'New password is required'),
22
+ });
23
+
24
+ const passwordConfirmationValidation = z.string({
25
+ required_error: t('passwordConfirmationRequired', 'Password confirmation is required'),
26
+ });
27
+
28
+ const changePasswordFormSchema = z
29
+ .object({
30
+ oldPassword: oldPasswordValidation,
31
+ newPassword: newPasswordValidation,
32
+ passwordConfirmation: passwordConfirmationValidation,
33
+ })
34
+ .refine((data) => data.newPassword === data.passwordConfirmation, {
35
+ message: t('passwordsDoNotMatch', 'Passwords do not match'),
36
+ path: ['passwordConfirmation'],
37
+ });
38
+
39
+ const {
40
+ handleSubmit,
41
+ control,
42
+ formState: { errors },
43
+ } = useForm({
44
+ resolver: zodResolver(changePasswordFormSchema),
45
+ });
46
+
47
+ const onSubmit: SubmitHandler<z.infer<typeof changePasswordFormSchema>> = useCallback(
48
+ (data) => {
49
+ setIsChangingPassword(true);
50
+
51
+ const { oldPassword, newPassword } = data;
52
+
53
+ changeUserPassword(oldPassword, newPassword)
54
+ .then(() => {
55
+ showSnackbar({
56
+ title: t('passwordChangedSuccessfully', 'Password changed successfully'),
57
+ kind: 'success',
58
+ });
59
+ })
60
+ .catch((error) => {
61
+ showSnackbar({
62
+ kind: 'error',
63
+ subtitle: error?.message,
64
+ title: t('errorChangingPassword', 'Error changing password'),
65
+ });
66
+ })
67
+ .finally(() => {
68
+ setIsChangingPassword(false);
69
+ });
70
+ },
71
+ [t],
72
+ );
73
+
74
+ const onError = useCallback(() => setIsChangingPassword(false), []);
75
+
76
+ return (
77
+ <div className={styles.container}>
78
+ <Tile className={styles.changePasswordCard}>
79
+ <div className={styles.alignCenter}>
80
+ <Logo t={t} />
81
+ </div>
82
+ <Form onSubmit={handleSubmit(onSubmit, onError)}>
83
+ <Controller
84
+ name="oldPassword"
85
+ control={control}
86
+ render={({ field: { onChange, value } }) => (
87
+ <PasswordInput
88
+ id="oldPassword"
89
+ invalid={!!errors?.oldPassword}
90
+ invalidText={
91
+ (errors &&
92
+ errors.oldPassword &&
93
+ errors.oldPassword.message &&
94
+ typeof errors.oldPassword.message === 'string' &&
95
+ errors.oldPassword.message) ??
96
+ ''
97
+ }
98
+ labelText={t('oldPassword', 'Old password')}
99
+ onChange={onChange}
100
+ value={value}
101
+ />
102
+ )}
103
+ />
104
+ <Controller
105
+ name="newPassword"
106
+ control={control}
107
+ render={({ field: { onChange, value } }) => (
108
+ <PasswordInput
109
+ id="newPassword"
110
+ invalid={!!errors?.newPassword}
111
+ invalidText={
112
+ (errors &&
113
+ errors.newPassword &&
114
+ errors.newPassword.message &&
115
+ typeof errors.newPassword.message === 'string' &&
116
+ errors.newPassword.message) ??
117
+ ''
118
+ }
119
+ labelText={t('newPassword', 'New password')}
120
+ onChange={onChange}
121
+ value={value}
122
+ />
123
+ )}
124
+ />
125
+ <Controller
126
+ name="passwordConfirmation"
127
+ control={control}
128
+ render={({ field: { onChange, value } }) => (
129
+ <PasswordInput
130
+ id="passwordConfirmation"
131
+ invalid={!!errors?.passwordConfirmation}
132
+ invalidText={
133
+ (errors &&
134
+ errors.passwordConfirmation &&
135
+ errors.passwordConfirmation.message &&
136
+ typeof errors.passwordConfirmation.message === 'string' &&
137
+ errors.passwordConfirmation.message) ??
138
+ ''
139
+ }
140
+ labelText={t('confirmPassword', 'Confirm new password')}
141
+ onChange={onChange}
142
+ value={value}
143
+ />
144
+ )}
145
+ />
146
+ <Button className={styles.submitButton} disabled={isChangingPassword} type="submit">
147
+ {isChangingPassword ? (
148
+ <InlineLoading description={t('changingPassword', 'Changing password') + '...'} />
149
+ ) : (
150
+ <span>{t('change', 'Change Password')}</span>
151
+ )}
152
+ </Button>
153
+ </Form>
154
+ </Tile>
155
+ </div>
156
+ );
157
+ };
158
+
159
+ export default ChangePassword;
@@ -0,0 +1,175 @@
1
+ import React, { useCallback, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { z } from 'zod';
4
+ import { zodResolver } from '@hookform/resolvers/zod';
5
+ import { Controller, useForm, type SubmitHandler } from 'react-hook-form';
6
+ import {
7
+ Button,
8
+ Form,
9
+ InlineLoading,
10
+ InlineNotification,
11
+ ModalBody,
12
+ ModalFooter,
13
+ ModalHeader,
14
+ PasswordInput,
15
+ Stack,
16
+ } from '@carbon/react';
17
+ import { showSnackbar } from '@openmrs/esm-framework';
18
+ import { changeUserPassword } from './change-password.resource';
19
+ import styles from './change-password-modal.scss';
20
+
21
+ interface ChangePasswordModalProps {
22
+ close(): () => void;
23
+ }
24
+
25
+ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ close }) => {
26
+ const { t } = useTranslation();
27
+ const [isChangingPassword, setIsChangingPassword] = useState(false);
28
+ const [errorMessage, setErrorMessage] = useState(null);
29
+
30
+ const oldPasswordValidation = z
31
+ .string({
32
+ required_error: t('oldPasswordRequired', 'Old password is required'),
33
+ })
34
+ .trim()
35
+ .min(1, t('oldPasswordRequired', 'Old password is required'));
36
+
37
+ const newPasswordValidation = z
38
+ .string({
39
+ required_error: t('newPasswordRequired', 'New password is required'),
40
+ })
41
+ .trim()
42
+ .min(1, t('newPasswordRequired', 'New password is required'));
43
+
44
+ const passwordConfirmationValidation = z
45
+ .string({
46
+ required_error: t('passwordConfirmationRequired', 'Password confirmation is required'),
47
+ })
48
+ .trim()
49
+ .min(1, t('passwordConfirmationRequired', 'Password confirmation is required'));
50
+
51
+ const changePasswordFormSchema = z
52
+ .object({
53
+ oldPassword: oldPasswordValidation,
54
+ newPassword: newPasswordValidation,
55
+ passwordConfirmation: passwordConfirmationValidation,
56
+ })
57
+ .refine((data) => data.newPassword === data.passwordConfirmation, {
58
+ message: t('passwordsDoNotMatch', 'Passwords do not match'),
59
+ path: ['passwordConfirmation'],
60
+ });
61
+
62
+ const {
63
+ handleSubmit,
64
+ control,
65
+ formState: { errors },
66
+ } = useForm({
67
+ resolver: zodResolver(changePasswordFormSchema),
68
+ defaultValues: {
69
+ oldPassword: '',
70
+ newPassword: '',
71
+ passwordConfirmation: '',
72
+ },
73
+ });
74
+
75
+ const onSubmit: SubmitHandler<z.infer<typeof changePasswordFormSchema>> = useCallback(
76
+ (data) => {
77
+ setIsChangingPassword(true);
78
+ const { oldPassword, newPassword } = data;
79
+
80
+ changeUserPassword(oldPassword, newPassword)
81
+ .then(() => {
82
+ close();
83
+
84
+ showSnackbar({
85
+ title: t('passwordChangedSuccessfully', 'Password changed successfully'),
86
+ kind: 'success',
87
+ });
88
+ })
89
+ .catch((error) => {
90
+ const errorMessage = error?.responseBody?.message ?? error?.message;
91
+ setErrorMessage(errorMessage);
92
+ })
93
+ .finally(() => {
94
+ setIsChangingPassword(false);
95
+ });
96
+ },
97
+ [close, t],
98
+ );
99
+
100
+ const onError = () => setIsChangingPassword(false);
101
+
102
+ return (
103
+ <Form onSubmit={handleSubmit(onSubmit, onError)}>
104
+ <ModalHeader closeModal={close} title={t('changePassword', 'Change password')} />
105
+ <ModalBody>
106
+ <Stack gap={5} className={styles.languageOptionsContainer}>
107
+ <Controller
108
+ name="oldPassword"
109
+ control={control}
110
+ render={({ field: { onChange, value } }) => (
111
+ <PasswordInput
112
+ id="oldPassword"
113
+ invalid={!!errors?.oldPassword}
114
+ invalidText={errors?.oldPassword?.message}
115
+ labelText={t('oldPassword', 'Old password')}
116
+ onChange={onChange}
117
+ value={value}
118
+ />
119
+ )}
120
+ />
121
+ <Controller
122
+ name="newPassword"
123
+ control={control}
124
+ render={({ field: { onChange, value } }) => (
125
+ <PasswordInput
126
+ id="newPassword"
127
+ invalid={!!errors?.newPassword}
128
+ invalidText={errors?.newPassword?.message}
129
+ labelText={t('newPassword', 'New password')}
130
+ onChange={onChange}
131
+ value={value}
132
+ />
133
+ )}
134
+ />
135
+ <Controller
136
+ name="passwordConfirmation"
137
+ control={control}
138
+ render={({ field: { onChange, value } }) => (
139
+ <PasswordInput
140
+ id="passwordConfirmation"
141
+ invalid={!!errors?.passwordConfirmation}
142
+ invalidText={errors?.passwordConfirmation?.message}
143
+ labelText={t('confirmPassword', 'Confirm new password')}
144
+ onChange={onChange}
145
+ value={value}
146
+ />
147
+ )}
148
+ />
149
+ {errorMessage && (
150
+ <InlineNotification
151
+ kind="error"
152
+ onClick={() => setErrorMessage('')}
153
+ subtitle={errorMessage}
154
+ title={t('errorChangingPassword', 'Error changing password')}
155
+ />
156
+ )}
157
+ </Stack>
158
+ </ModalBody>
159
+ <ModalFooter>
160
+ <Button kind="secondary" onClick={close}>
161
+ {t('cancel', 'Cancel')}
162
+ </Button>
163
+ <Button className={styles.submitButton} disabled={isChangingPassword} type="submit">
164
+ {isChangingPassword ? (
165
+ <InlineLoading description={t('changingPassword', 'Changing password') + '...'} />
166
+ ) : (
167
+ <span>{t('change', 'Change')}</span>
168
+ )}
169
+ </Button>
170
+ </ModalFooter>
171
+ </Form>
172
+ );
173
+ };
174
+
175
+ export default ChangePasswordModal;
@@ -0,0 +1,12 @@
1
+ import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
+
3
+ export function changeUserPassword(oldPassword: string, newPassword: string) {
4
+ return openmrsFetch(`${restBaseUrl}/password`, {
5
+ method: 'POST',
6
+ headers: { 'Content-Type': 'application/json' },
7
+ body: {
8
+ oldPassword,
9
+ newPassword,
10
+ },
11
+ });
12
+ }