@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,246 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .bodyShort01 {
6
+ @include type.type-style('body-compact-01');
7
+ }
8
+
9
+ .productiveHeading03 {
10
+ @include type.type-style('heading-03');
11
+ }
12
+
13
+ .label01 {
14
+ @include type.type-style('label-01');
15
+ }
16
+
17
+ .caption01 {
18
+ @include type.type-style('legal-01');
19
+ }
20
+
21
+ .backButton {
22
+ height: layout.$spacing-09;
23
+ padding-left: 0;
24
+
25
+ svg {
26
+ margin-right: layout.$spacing-03;
27
+ order: 1;
28
+ }
29
+
30
+ span {
31
+ order: 2;
32
+ }
33
+ }
34
+
35
+ .container {
36
+ display: flex;
37
+ flex-direction: row;
38
+ justify-content: flex-start;
39
+ align-items: center;
40
+ position: relative;
41
+ margin-left: 10rem;
42
+ }
43
+
44
+ .center {
45
+ text-align: center;
46
+ }
47
+
48
+ .logo {
49
+ // margin-bottom: layout.$spacing-08;
50
+ height: layout.$spacing-11;
51
+ width: 16rem;
52
+ }
53
+
54
+ .logoImg {
55
+ // margin-bottom: layout.$spacing-03;
56
+ max-width: 100%;
57
+ }
58
+
59
+ .poweredByTxt {
60
+ @extend .caption01;
61
+ text-align: center;
62
+ color: $color-gray-70;
63
+ color: $text-02;
64
+ }
65
+
66
+ .needHelp {
67
+ display: flex;
68
+ width: 23rem;
69
+ }
70
+
71
+ .needHelpText {
72
+ @extend .bodyShort01;
73
+ text-align: left;
74
+ color: $text-02;
75
+ display: flex;
76
+ flex-direction: row;
77
+ align-items: center;
78
+ margin: layout.$spacing-05 0;
79
+ }
80
+
81
+ .link {
82
+ text-decoration-line: none;
83
+ margin-left: layout.$spacing-03;
84
+ }
85
+
86
+ .loginCard {
87
+ border-radius: 0;
88
+ border: 1px solid $ui-03;
89
+ background-color: $ui-02;
90
+ width: 23rem;
91
+ padding: layout.$spacing-08;
92
+ border-radius: layout.$spacing-03;
93
+ position: relative;
94
+ min-height: fit-content;
95
+ }
96
+
97
+ @media only screen and (min-width: 481px) {
98
+ .container {
99
+ height: 100vh;
100
+ }
101
+ }
102
+
103
+ @media only screen and (max-width: 1024px) {
104
+ .container {
105
+ height: 100vh;
106
+ }
107
+ }
108
+
109
+ @media only screen and (max-width: 480px) {
110
+ .container {
111
+ height: 100vh;
112
+ }
113
+ }
114
+
115
+ .inputGroup {
116
+ display: flex;
117
+ justify-content: center;
118
+ flex-direction: column;
119
+ align-items: center;
120
+ width: 18rem;
121
+ gap: layout.$spacing-05;
122
+
123
+ :global(.cds--text-input) {
124
+ height: layout.$spacing-09;
125
+ @extend .label01;
126
+ }
127
+
128
+ :global(.cds--text-input__field-outer-wrapper) {
129
+ width: 18rem;
130
+ }
131
+
132
+ &:not(:last-child) {
133
+ margin-bottom: layout.$spacing-05;
134
+ }
135
+ }
136
+
137
+ .continueButton {
138
+ margin-top: layout.$spacing-06;
139
+ width: 18rem;
140
+ }
141
+
142
+ .backButtonDiv {
143
+ width: 23rem;
144
+ position: absolute;
145
+ bottom: 100%;
146
+ left: 0;
147
+ }
148
+
149
+ .loader {
150
+ min-height: fit-content;
151
+ }
152
+
153
+ .errorMessage {
154
+ width: 23rem;
155
+ margin-bottom: layout.$spacing-09;
156
+ position: absolute;
157
+ bottom: 100%;
158
+ left: 0;
159
+ }
160
+
161
+ // Keep password field in DOM for browser autofill
162
+ .hiddenPasswordField {
163
+ visibility: hidden;
164
+ height: 0;
165
+ overflow: hidden;
166
+ position: absolute;
167
+ pointer-events: none;
168
+ }
169
+
170
+ .wrapperContainer {
171
+ display: flex;
172
+ flex-direction: row;
173
+ gap: 10rem;
174
+ }
175
+
176
+ .image {
177
+ width: 50rem;
178
+ height: 55rem;
179
+ max-height: 60rem;
180
+ object-fit: cover;
181
+ }
182
+
183
+ .poweredBy {
184
+ display: flex;
185
+ align-items: center;
186
+ justify-self: flex-end;
187
+ font-size: 0.75rem;
188
+ color: blue;
189
+ opacity: 0.85;
190
+ white-space: nowrap;
191
+ font-weight: bold;
192
+ margin-bottom: layout.$spacing-08;
193
+ }
194
+
195
+ .poweredByLogo {
196
+ margin-left: 0.5rem;
197
+ width: 2rem;
198
+ height: auto;
199
+ object-fit: contain;
200
+ border-radius: 0.5rem;
201
+ }
202
+
203
+ .logoContainer {
204
+ display: flex;
205
+ flex-direction: column;
206
+ }
207
+
208
+ .logoSection {
209
+ position: fixed;
210
+ bottom: layout.$spacing-01;
211
+ display: flex;
212
+ justify-content: space-between;
213
+ align-items: center;
214
+ padding: 0 layout.$spacing-04;
215
+ gap: 24rem;
216
+ }
217
+
218
+ .leftLogos {
219
+ display: flex;
220
+ flex-direction: row;
221
+ gap: 1rem;
222
+ border: 2px solid lightgray;
223
+ border-radius: 0.5rem;
224
+ }
225
+
226
+ .supportingLogos {
227
+ margin-left: 0.5rem;
228
+ width: 3rem;
229
+ height: auto;
230
+ object-fit: contain;
231
+ border-radius: 0.5rem;
232
+ }
233
+ .dhaLogo {
234
+ margin-left: 0.5rem;
235
+ width: 4rem;
236
+ height: auto;
237
+ object-fit: contain;
238
+ border-radius: 0.5rem;
239
+ }
240
+ .openmsrsLogo {
241
+ margin-left: 0.5rem;
242
+ width: 6rem;
243
+ height: auto;
244
+ object-fit: contain;
245
+ border-radius: 0.5rem;
246
+ }
@@ -0,0 +1,288 @@
1
+ import { useState } from 'react';
2
+ import { waitFor, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { getSessionStore, refetchCurrentUser, type SessionStore, useConfig, useSession } from '@openmrs/esm-framework';
5
+ import { mockConfig } from '../../__mocks__/config.mock';
6
+ import renderWithRouter from '../test-helpers/render-with-router';
7
+ import Login from './login.component';
8
+
9
+ const mockGetSessionStore = jest.mocked(getSessionStore);
10
+ const mockLogin = jest.mocked(refetchCurrentUser);
11
+ const mockUseConfig = jest.mocked(useConfig);
12
+ const mockUseSession = jest.mocked(useSession);
13
+
14
+ mockLogin.mockResolvedValue({} as SessionStore);
15
+ mockGetSessionStore.mockImplementation(() => {
16
+ return {
17
+ getState: jest.fn().mockReturnValue({
18
+ loaded: true,
19
+ session: {
20
+ authenticated: true,
21
+ },
22
+ }),
23
+ setState: jest.fn(),
24
+ getInitialState: jest.fn(),
25
+ subscribe: jest.fn(),
26
+ destroy: jest.fn(),
27
+ };
28
+ });
29
+
30
+ const loginLocations = [
31
+ { uuid: '111', display: 'Earth' },
32
+ { uuid: '222', display: 'Mars' },
33
+ ];
34
+
35
+ mockUseSession.mockReturnValue({ authenticated: false, sessionId: '123' });
36
+ mockUseConfig.mockReturnValue(mockConfig);
37
+
38
+ describe('Login', () => {
39
+ it('renders the login form', () => {
40
+ renderWithRouter(
41
+ Login,
42
+ {},
43
+ {
44
+ route: '/login',
45
+ },
46
+ );
47
+
48
+ expect(screen.getAllByRole('img', { name: /OpenMRS logo/i })).toHaveLength(2);
49
+ expect(screen.queryByAltText(/^logo$/i)).not.toBeInTheDocument();
50
+ screen.getByRole('textbox', { name: /Username/i });
51
+ screen.getByRole('button', { name: /Continue/i });
52
+ });
53
+
54
+ it('renders a configurable logo', () => {
55
+ const customLogoConfig = {
56
+ src: 'https://some-image-host.com/foo.png',
57
+ alt: 'Custom logo',
58
+ };
59
+ mockUseConfig.mockReturnValue({
60
+ ...mockConfig,
61
+ logo: customLogoConfig,
62
+ });
63
+
64
+ renderWithRouter(Login);
65
+
66
+ const logo = screen.getByAltText(customLogoConfig.alt);
67
+
68
+ expect(screen.queryByTitle(/openmrs logo/i)).not.toBeInTheDocument();
69
+ expect(logo).toHaveAttribute('src', customLogoConfig.src);
70
+ expect(logo).toHaveAttribute('alt', customLogoConfig.alt);
71
+ });
72
+
73
+ it('should return user focus to username input when input is invalid', async () => {
74
+ renderWithRouter(
75
+ Login,
76
+ {},
77
+ {
78
+ route: '/login',
79
+ },
80
+ );
81
+ const user = userEvent.setup();
82
+
83
+ expect(screen.getByRole('textbox', { name: /username/i })).toBeInTheDocument();
84
+ // no input to username
85
+ const continueButton = screen.getByRole('button', { name: /Continue/i });
86
+ await user.click(continueButton);
87
+ expect(screen.getByRole('textbox', { name: /username/i })).toHaveFocus();
88
+ await user.type(screen.getByRole('textbox', { name: /username/i }), 'yoshi');
89
+ await user.click(continueButton);
90
+ await screen.findByLabelText(/^password$/i);
91
+ await user.type(screen.getByLabelText(/^password$/i), 'no-tax-fraud');
92
+ expect(screen.getByLabelText(/^password$/i)).toHaveFocus();
93
+ });
94
+
95
+ it('makes an API request when you submit the form', async () => {
96
+ mockLogin.mockResolvedValue({ some: 'data' } as unknown as SessionStore);
97
+
98
+ renderWithRouter(
99
+ Login,
100
+ {},
101
+ {
102
+ route: '/login',
103
+ },
104
+ );
105
+ const user = userEvent.setup();
106
+
107
+ mockLogin.mockClear();
108
+ await user.type(screen.getByRole('textbox', { name: /Username/i }), 'yoshi');
109
+ await user.click(screen.getByRole('button', { name: /Continue/i }));
110
+
111
+ const loginButton = screen.getByRole('button', { name: /log in/i });
112
+ await screen.findByLabelText(/^password$/i);
113
+ await user.type(screen.getByLabelText(/^password$/i), 'no-tax-fraud');
114
+ await user.click(loginButton);
115
+ await waitFor(() => expect(refetchCurrentUser).toHaveBeenCalledWith('yoshi', 'no-tax-fraud'));
116
+ });
117
+
118
+ // TODO: Complete the test
119
+ it('sends the user to the location select page on login if there is more than one location', async () => {
120
+ let refreshUser = (user: any) => {};
121
+ mockLogin.mockImplementation(() => {
122
+ refreshUser({
123
+ display: 'my name',
124
+ });
125
+ return Promise.resolve({ data: { authenticated: true } } as unknown as SessionStore);
126
+ });
127
+ mockUseSession.mockImplementation(() => {
128
+ const [user, setUser] = useState();
129
+ refreshUser = setUser;
130
+ return { user, authenticated: !!user, sessionId: '123' };
131
+ });
132
+
133
+ renderWithRouter(
134
+ Login,
135
+ {},
136
+ {
137
+ route: '/login',
138
+ },
139
+ );
140
+
141
+ const user = userEvent.setup();
142
+
143
+ await user.type(screen.getByRole('textbox', { name: /Username/i }), 'yoshi');
144
+ await user.click(screen.getByRole('button', { name: /Continue/i }));
145
+ await screen.findByLabelText(/^password$/i);
146
+ await user.type(screen.getByLabelText(/^password$/i), 'no-tax-fraud');
147
+ await user.click(screen.getByRole('button', { name: /log in/i }));
148
+ });
149
+
150
+ it('should render the both the username and password fields when the showPasswordOnSeparateScreen config is false', async () => {
151
+ mockUseConfig.mockReturnValue({
152
+ ...mockConfig,
153
+ showPasswordOnSeparateScreen: false,
154
+ });
155
+
156
+ renderWithRouter(
157
+ Login,
158
+ {},
159
+ {
160
+ route: '/login',
161
+ },
162
+ );
163
+
164
+ const usernameInput = screen.queryByRole('textbox', { name: /username/i });
165
+ const continueButton = screen.queryByRole('button', { name: /Continue/i });
166
+ const passwordInput = screen.queryByLabelText(/^password$/i);
167
+ const loginButton = screen.queryByRole('button', { name: /log in/i });
168
+
169
+ expect(usernameInput).toBeInTheDocument();
170
+ expect(continueButton).not.toBeInTheDocument();
171
+ expect(passwordInput).toBeInTheDocument();
172
+ expect(loginButton).toBeInTheDocument();
173
+ });
174
+
175
+ it('should render password field hidden but present for autofill when showPasswordOnSeparateScreen config is true (default)', async () => {
176
+ mockUseConfig.mockReturnValue({
177
+ ...mockConfig,
178
+ });
179
+
180
+ renderWithRouter(
181
+ Login,
182
+ {},
183
+ {
184
+ route: '/login',
185
+ },
186
+ );
187
+
188
+ const usernameInput = screen.queryByRole('textbox', { name: /username/i });
189
+ const continueButton = screen.queryByRole('button', { name: /Continue/i });
190
+ const passwordInput = screen.queryByLabelText(/^password$/i);
191
+ const loginButton = screen.queryByRole('button', { name: /log in/i });
192
+
193
+ expect(usernameInput).toBeInTheDocument();
194
+ expect(continueButton).toBeInTheDocument();
195
+ expect(passwordInput).toBeInTheDocument();
196
+ expect(passwordInput).toHaveAttribute('aria-hidden', 'true');
197
+ expect(passwordInput).toHaveAttribute('tabIndex', '-1');
198
+ expect(loginButton).not.toBeInTheDocument();
199
+ });
200
+
201
+ it('should be able to login when the showPasswordOnSeparateScreen config is false', async () => {
202
+ mockLogin.mockResolvedValue({ some: 'data' } as unknown as SessionStore);
203
+ mockUseConfig.mockReturnValue({
204
+ ...mockConfig,
205
+ showPasswordOnSeparateScreen: false,
206
+ });
207
+ const user = userEvent.setup();
208
+ mockLogin.mockClear();
209
+
210
+ renderWithRouter(
211
+ Login,
212
+ {},
213
+ {
214
+ route: '/login',
215
+ },
216
+ );
217
+
218
+ const usernameInput = screen.getByRole('textbox', { name: /username/i });
219
+ const passwordInput = screen.getByLabelText(/^password$/i);
220
+ const loginButton = screen.getByRole('button', { name: /log in/i });
221
+
222
+ await user.type(usernameInput, 'yoshi');
223
+ await user.type(passwordInput, 'no-tax-fraud');
224
+ await user.click(loginButton);
225
+
226
+ await waitFor(() => expect(refetchCurrentUser).toHaveBeenCalledWith('yoshi', 'no-tax-fraud'));
227
+ });
228
+
229
+ it('should focus the username input', async () => {
230
+ mockUseConfig.mockReturnValue({
231
+ ...mockConfig,
232
+ });
233
+
234
+ renderWithRouter(
235
+ Login,
236
+ {},
237
+ {
238
+ route: '/login',
239
+ },
240
+ );
241
+
242
+ const usernameInput = screen.getByRole('textbox', { name: /username/i });
243
+ expect(usernameInput).toHaveFocus();
244
+ });
245
+
246
+ it('should focus the password input in the password screen', async () => {
247
+ const user = userEvent.setup();
248
+ mockUseConfig.mockReturnValue({
249
+ ...mockConfig,
250
+ });
251
+
252
+ renderWithRouter(
253
+ Login,
254
+ {},
255
+ {
256
+ route: '/login',
257
+ },
258
+ );
259
+
260
+ const usernameInput = screen.getByRole('textbox', { name: /username/i });
261
+ const continueButton = screen.getByRole('button', { name: /Continue/i });
262
+
263
+ await user.type(usernameInput, 'yoshi');
264
+ await user.click(continueButton);
265
+
266
+ const passwordInput = screen.getByLabelText(/^password$/i);
267
+ expect(passwordInput).toHaveFocus();
268
+ });
269
+
270
+ it('should focus the username input when the showPasswordOnSeparateScreen config is false', async () => {
271
+ mockUseConfig.mockReturnValue({
272
+ ...mockConfig,
273
+ showPasswordOnSeparateScreen: false,
274
+ });
275
+
276
+ renderWithRouter(
277
+ Login,
278
+ {},
279
+ {
280
+ route: '/login',
281
+ },
282
+ );
283
+
284
+ const usernameInput = screen.getByRole('textbox', { name: /username/i });
285
+
286
+ expect(usernameInput).toHaveFocus();
287
+ });
288
+ });
@@ -0,0 +1,147 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import useSwrInfinite, { type SWRInfiniteResponse } from 'swr/infinite';
4
+ import useSwrImmutable from 'swr/immutable';
5
+ import {
6
+ fhirBaseUrl,
7
+ openmrsFetch,
8
+ refetchCurrentUser,
9
+ restBaseUrl,
10
+ type FetchResponse,
11
+ type Session,
12
+ useDebounce,
13
+ } from '@openmrs/esm-framework';
14
+ import type { LocationEntry, LocationResponse } from './types';
15
+
16
+ // "swr/infinite" doesn't export InfiniteKeyedMutator directly
17
+ type InfiniteKeyedMutator<T> = SWRInfiniteResponse<T extends (infer I)[] ? I : T>['mutate'];
18
+
19
+ interface LoginLocationData {
20
+ error: Error;
21
+ hasMore: boolean;
22
+ isLoading: boolean;
23
+ loadingNewData: boolean;
24
+ locations: Array<LocationEntry>;
25
+ mutate: InfiniteKeyedMutator<FetchResponse<LocationResponse>[]>;
26
+ setPage: (size: number | ((_size: number) => number)) => Promise<FetchResponse<LocationResponse>[]>;
27
+ totalResults: number;
28
+ }
29
+
30
+ export function useLoginLocations(
31
+ count: number = 0,
32
+ searchQuery: string = '',
33
+ useLoginLocationTag: boolean,
34
+ ): LoginLocationData {
35
+ const { t } = useTranslation();
36
+ const debouncedSearchQuery = useDebounce(searchQuery);
37
+
38
+ function constructUrl(page: number, prevPageData: FetchResponse<LocationResponse>) {
39
+ if (prevPageData) {
40
+ const nextLink = prevPageData.data?.link?.find((link) => link.relation === 'next');
41
+
42
+ if (!nextLink) {
43
+ return null;
44
+ }
45
+
46
+ const nextUrl = new URL(nextLink.url);
47
+ // default for production
48
+ if (nextUrl.origin === window.location.origin) {
49
+ return nextLink.url;
50
+ }
51
+
52
+ // in development, the request should be funnelled through the local proxy
53
+ return new URL(
54
+ `${nextUrl.pathname}${nextUrl.search ? `?${nextUrl.search}` : ''}`,
55
+ window.location.origin,
56
+ ).toString();
57
+ }
58
+
59
+ let url = `${fhirBaseUrl}/Location?`;
60
+ let urlSearchParameters = new URLSearchParams();
61
+ urlSearchParameters.append('_summary', 'data');
62
+
63
+ if (count) {
64
+ urlSearchParameters.append('_count', '' + count);
65
+ }
66
+
67
+ if (page) {
68
+ urlSearchParameters.append('_getpagesoffset', '' + page * count);
69
+ }
70
+
71
+ if (useLoginLocationTag) {
72
+ urlSearchParameters.append('_tag', 'Login Location');
73
+ }
74
+
75
+ if (typeof debouncedSearchQuery === 'string' && debouncedSearchQuery != '') {
76
+ urlSearchParameters.append('name:contains', debouncedSearchQuery);
77
+ }
78
+
79
+ return url + urlSearchParameters.toString();
80
+ }
81
+
82
+ const { data, isLoading, isValidating, setSize, error, mutate } = useSwrInfinite<
83
+ FetchResponse<LocationResponse>,
84
+ Error
85
+ >(constructUrl, openmrsFetch);
86
+
87
+ useEffect(() => {
88
+ if (error) {
89
+ console.error(error);
90
+ }
91
+ }, [error]);
92
+
93
+ const memoizedLocations = useMemo(() => {
94
+ return {
95
+ locations: data?.length ? data?.flatMap((entries) => entries?.data?.entry ?? []) : null,
96
+ isLoading,
97
+ totalResults: data?.[0]?.data?.total ?? null,
98
+ hasMore: data?.length ? data?.[data.length - 1]?.data?.link?.some((link) => link.relation === 'next') : false,
99
+ loadingNewData: isValidating,
100
+ setPage: setSize,
101
+ error,
102
+ mutate,
103
+ };
104
+ }, [isLoading, data, isValidating, setSize, error, mutate]);
105
+
106
+ return memoizedLocations;
107
+ }
108
+
109
+ export async function performLogin(username: string, password: string): Promise<{ data: Session }> {
110
+ const abortController = new AbortController();
111
+ const token = window.btoa(`${username}:${password}`);
112
+ const url = `${restBaseUrl}/session`;
113
+
114
+ return openmrsFetch(url, {
115
+ headers: {
116
+ Authorization: `Basic ${token}`,
117
+ },
118
+ signal: abortController.signal,
119
+ }).then((res) => {
120
+ refetchCurrentUser();
121
+ return res;
122
+ });
123
+ }
124
+ export function useValidateLocationUuid(userPreferredLocationUuid: string) {
125
+ const url = userPreferredLocationUuid ? `${fhirBaseUrl}/Location?_id=${userPreferredLocationUuid}` : null;
126
+ const { data, error, isLoading } = useSwrImmutable<FetchResponse<LocationResponse>>(url, openmrsFetch, {
127
+ shouldRetryOnError(err) {
128
+ if (err?.response?.status) {
129
+ return err.response.status >= 500;
130
+ }
131
+
132
+ return false;
133
+ },
134
+ });
135
+
136
+ const results = useMemo(
137
+ () => ({
138
+ isLocationValid: data?.ok && data?.data.total > 0,
139
+ defaultLocation: data?.data?.entry ?? [],
140
+ error,
141
+ isLoading,
142
+ }),
143
+ [data, isLoading, error],
144
+ );
145
+
146
+ return results;
147
+ }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { interpolateUrl, useConfig } from '@openmrs/esm-framework';
3
+ import { type TFunction } from 'i18next';
4
+ import { type ConfigSchema } from './config-schema';
5
+ import styles from './login/login.scss';
6
+ import taifaCare from './assets/Taifa-Care.png';
7
+ import amrsLogo from './assets/ampath-logo.png';
8
+
9
+ const Logo: React.FC<{ t: TFunction }> = ({ t }) => {
10
+ const { logo } = useConfig<ConfigSchema>();
11
+ return (
12
+ <>
13
+ <img alt={logo.alt ? t(logo.alt) : t('openmrsLogo', 'OpenMRS logo')} className={styles.logoImg} src={taifaCare} />
14
+ <span className={styles.poweredBy}>
15
+ {t('poweredBy', 'Powered by AMRS')}{' '}
16
+ <img src={amrsLogo} alt={t('taifaCare', 'TAIFA CARE logo')} className={styles.poweredByLogo} />
17
+ </span>
18
+ </>
19
+ );
20
+ };
21
+
22
+ export default Logo;