@openmrs/esm-api 10.0.1-pre.5605 → 10.0.1-pre.5616

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.
@@ -10,9 +10,12 @@ import {
10
10
  setSessionLocation,
11
11
  setUserProperties,
12
12
  sessionStore,
13
+ setSessionLocale,
14
+ verifyTotpCode,
15
+ logout,
13
16
  } from './current-user';
14
17
  import type * as openmrsFetchExport from './openmrs-fetch';
15
- import { openmrsFetch } from './openmrs-fetch';
18
+ import { OpenmrsFetchError, openmrsFetch } from './openmrs-fetch';
16
19
  import { reportError } from '@openmrs/esm-error-handling';
17
20
  import type { LoggedInUser, Privilege, Role, Session } from './types';
18
21
 
@@ -32,6 +35,10 @@ vi.mock('@openmrs/esm-error-handling', () => ({
32
35
  const mockOpenmrsFetch = vi.mocked(openmrsFetch);
33
36
  const mockReportError = vi.mocked(reportError);
34
37
 
38
+ function createAuthFailure(status: number, responseBody: unknown = null) {
39
+ return new OpenmrsFetchError('/openmrs/ws/rest/v1/session', { status } as Response, responseBody as any, Error());
40
+ }
41
+
35
42
  // Helper to create mock fetch responses
36
43
  function createMockFetchResponse<T>(data: T, ok = true): any {
37
44
  return {
@@ -247,7 +254,7 @@ describe('setUserLanguage', () => {
247
254
  describe('getLoggedInUser', () => {
248
255
  beforeEach(() => {
249
256
  // Reset session store
250
- sessionStore.setState({ loaded: false, session: null });
257
+ sessionStore.setState({ loaded: false, session: null }, true);
251
258
  });
252
259
 
253
260
  it('should return logged in user when session is loaded', async () => {
@@ -314,7 +321,7 @@ describe('getLoggedInUser', () => {
314
321
 
315
322
  describe('getCurrentUser', () => {
316
323
  beforeEach(() => {
317
- sessionStore.setState({ loaded: false, session: null });
324
+ sessionStore.setState({ loaded: false, session: null }, true);
318
325
  mockOpenmrsFetch.mockClear();
319
326
  mockReportError.mockClear();
320
327
  // Mock openmrsFetch to prevent unhandled promise rejections
@@ -446,6 +453,23 @@ describe('getCurrentUser', () => {
446
453
  expect(resolvedSession).toEqual(mockSession);
447
454
  });
448
455
 
456
+ it('should resolve with the last loaded session when a refresh fails', async () => {
457
+ vi.useFakeTimers();
458
+
459
+ try {
460
+ const staleSession: Session = { authenticated: true, sessionId: 'stale-session', user: buildMockUser() };
461
+ mockOpenmrsFetch.mockResolvedValue(createMockFetchResponse(staleSession));
462
+ await refetchCurrentUser();
463
+
464
+ vi.setSystemTime(Date.now() + 61 * 1000);
465
+ mockOpenmrsFetch.mockRejectedValue(new Error('Bad gateway'));
466
+
467
+ await expect(getCurrentUser({ includeAuthStatus: true })).resolves.toEqual(staleSession);
468
+ } finally {
469
+ vi.useRealTimers();
470
+ }
471
+ });
472
+
449
473
  it('should not resolve until the session is loaded', async () => {
450
474
  // Prevent the automatic refetch from resolving the session so the only way
451
475
  // the promise settles is the manual store update below.
@@ -476,7 +500,7 @@ describe('getCurrentUser', () => {
476
500
 
477
501
  describe('refetchCurrentUser', () => {
478
502
  beforeEach(() => {
479
- sessionStore.setState({ loaded: false, session: null });
503
+ sessionStore.setState({ loaded: false, session: null }, true);
480
504
  mockOpenmrsFetch.mockClear();
481
505
  });
482
506
 
@@ -495,6 +519,7 @@ describe('refetchCurrentUser', () => {
495
519
  expect.stringContaining('/session'),
496
520
  expect.objectContaining({
497
521
  headers: {},
522
+ rejectAuthFailure: true,
498
523
  }),
499
524
  );
500
525
  });
@@ -517,6 +542,7 @@ describe('refetchCurrentUser', () => {
517
542
  headers: {
518
543
  Authorization: expectedAuth,
519
544
  },
545
+ rejectAuthFailure: true,
520
546
  }),
521
547
  );
522
548
  });
@@ -561,6 +587,69 @@ describe('refetchCurrentUser', () => {
561
587
 
562
588
  expect(mockReportError).toHaveBeenCalled();
563
589
  });
590
+
591
+ it('should record the error when the fetch fails before any session has loaded', async () => {
592
+ const error = new Error('Bad gateway');
593
+ mockOpenmrsFetch.mockRejectedValue(error);
594
+
595
+ await expect(refetchCurrentUser()).rejects.toEqual({ loaded: false, session: null, error });
596
+ expect(sessionStore.getState()).toEqual({ loaded: false, session: null, error });
597
+ });
598
+
599
+ it('should keep an already-loaded session when a later fetch fails', async () => {
600
+ const mockSession: Session = { authenticated: true, sessionId: 'test-session', user: {} as LoggedInUser };
601
+ sessionStore.setState({ loaded: true, session: mockSession }, true);
602
+ mockReportError.mockClear();
603
+ mockOpenmrsFetch.mockRejectedValue(new Error('Bad gateway'));
604
+
605
+ await expect(refetchCurrentUser()).rejects.toEqual({ loaded: true, session: mockSession });
606
+ expect(sessionStore.getState()).toEqual({ loaded: true, session: mockSession });
607
+ expect(mockReportError).toHaveBeenCalled();
608
+ });
609
+
610
+ it('should record an error when the session endpoint does not respond with a session', async () => {
611
+ mockOpenmrsFetch.mockResolvedValue(createMockFetchResponse('<html>Bad gateway</html>'));
612
+
613
+ await expect(refetchCurrentUser()).rejects.toMatchObject({ loaded: false, session: null });
614
+ expect(sessionStore.getState()).toMatchObject({
615
+ loaded: false,
616
+ error: new Error('The session endpoint did not respond with a session'),
617
+ });
618
+ });
619
+
620
+ it('should use the unauthenticated session in the body of a 401 response', async () => {
621
+ const unauthenticated = { authenticated: false, sessionId: 'abc', allowedLocales: ['en', 'fr'] };
622
+ mockOpenmrsFetch.mockRejectedValue(createAuthFailure(401, unauthenticated));
623
+
624
+ await expect(refetchCurrentUser()).resolves.toEqual({ loaded: true, session: unauthenticated });
625
+ });
626
+
627
+ it.each([401, 403])(
628
+ 'should record a logged-out session when the session endpoint responds with %i',
629
+ async (status) => {
630
+ mockReportError.mockClear();
631
+ mockOpenmrsFetch.mockRejectedValue(createAuthFailure(status));
632
+
633
+ await expect(refetchCurrentUser()).resolves.toEqual({
634
+ loaded: true,
635
+ session: { authenticated: false, sessionId: '' },
636
+ });
637
+
638
+ expect(sessionStore.getState()).toEqual({
639
+ loaded: true,
640
+ session: { authenticated: false, sessionId: '' },
641
+ });
642
+ expect(mockReportError).not.toHaveBeenCalled();
643
+ },
644
+ );
645
+
646
+ it('should not leave readers waiting on the failed request after an auth failure', async () => {
647
+ mockOpenmrsFetch.mockRejectedValueOnce(createAuthFailure(401));
648
+ await refetchCurrentUser();
649
+
650
+ await expect(getCurrentUser()).resolves.toEqual({ authenticated: false, sessionId: '' });
651
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(1);
652
+ });
564
653
  });
565
654
 
566
655
  describe('setSessionLocation', () => {
@@ -592,6 +681,7 @@ describe('setSessionLocation', () => {
592
681
  headers: {
593
682
  'Content-Type': 'application/json',
594
683
  },
684
+ rejectAuthFailure: true,
595
685
  signal: abortController.signal,
596
686
  }),
597
687
  );
@@ -632,24 +722,21 @@ describe('setUserProperties', () => {
632
722
  favoriteColor: 'blue',
633
723
  };
634
724
 
635
- mockOpenmrsFetch
636
- .mockResolvedValueOnce(createMockFetchResponse({})) // First call to update properties
637
- .mockResolvedValueOnce(
638
- createMockFetchResponse({
639
- // Second call to refetch session
640
- authenticated: true,
641
- sessionId: 'test-session',
642
- user: {
643
- uuid: userUuid,
644
- userProperties,
645
- } as unknown as LoggedInUser,
646
- }),
647
- );
725
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse({}));
726
+ mockOpenmrsFetch.mockResolvedValueOnce(
727
+ createMockFetchResponse({
728
+ authenticated: true,
729
+ sessionId: 'test-session',
730
+ user: {
731
+ uuid: userUuid,
732
+ userProperties,
733
+ } as unknown as LoggedInUser,
734
+ }),
735
+ );
648
736
 
649
737
  await setUserProperties(userUuid, userProperties);
650
738
 
651
- expect(mockOpenmrsFetch).toHaveBeenNthCalledWith(
652
- 1,
739
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
653
740
  expect.stringContaining(`/user/${userUuid}`),
654
741
  expect.objectContaining({
655
742
  method: 'POST',
@@ -669,7 +756,8 @@ describe('setUserProperties', () => {
669
756
  const userProperties = { defaultLocale: 'fr-FR' };
670
757
  const abortController = new AbortController();
671
758
 
672
- mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse({})).mockResolvedValueOnce(
759
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse({}));
760
+ mockOpenmrsFetch.mockResolvedValueOnce(
673
761
  createMockFetchResponse({
674
762
  authenticated: true,
675
763
  sessionId: 'test-session',
@@ -679,8 +767,7 @@ describe('setUserProperties', () => {
679
767
 
680
768
  await setUserProperties(userUuid, userProperties, abortController);
681
769
 
682
- expect(mockOpenmrsFetch).toHaveBeenNthCalledWith(
683
- 1,
770
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
684
771
  expect.anything(),
685
772
  expect.objectContaining({
686
773
  signal: abortController.signal,
@@ -692,7 +779,8 @@ describe('setUserProperties', () => {
692
779
  const userUuid = 'user-uuid-123';
693
780
  const userProperties = { defaultLocale: 'es-ES' };
694
781
 
695
- mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse({})).mockResolvedValueOnce(
782
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse({}));
783
+ mockOpenmrsFetch.mockResolvedValueOnce(
696
784
  createMockFetchResponse({
697
785
  authenticated: true,
698
786
  sessionId: 'test-session',
@@ -702,8 +790,7 @@ describe('setUserProperties', () => {
702
790
 
703
791
  await setUserProperties(userUuid, userProperties);
704
792
 
705
- expect(mockOpenmrsFetch).toHaveBeenNthCalledWith(
706
- 1,
793
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
707
794
  expect.anything(),
708
795
  expect.objectContaining({
709
796
  signal: expect.any(AbortSignal),
@@ -711,3 +798,121 @@ describe('setUserProperties', () => {
711
798
  );
712
799
  });
713
800
  });
801
+
802
+ describe('setSessionLocale', () => {
803
+ beforeEach(() => {
804
+ mockOpenmrsFetch.mockClear();
805
+ });
806
+
807
+ it('should set the session locale and refetch the session', async () => {
808
+ const mockSession: Session = { authenticated: true, sessionId: 'test-session', locale: 'fr' };
809
+ const abortController = new AbortController();
810
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse(mockSession));
811
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse(mockSession));
812
+
813
+ await expect(setSessionLocale('fr', abortController)).resolves.toEqual({ loaded: true, session: mockSession });
814
+
815
+ expect(mockOpenmrsFetch).toHaveBeenCalledWith(
816
+ '/ws/rest/v1/session',
817
+ expect.objectContaining({
818
+ method: 'POST',
819
+ body: { locale: 'fr' },
820
+ signal: abortController.signal,
821
+ }),
822
+ );
823
+ expect(mockOpenmrsFetch).toHaveBeenLastCalledWith(
824
+ '/ws/rest/v1/session',
825
+ expect.objectContaining({ rejectAuthFailure: true }),
826
+ );
827
+ });
828
+
829
+ it('should reject with the fetch error when the server does not accept the locale', async () => {
830
+ const error = new OpenmrsFetchError('/openmrs/ws/rest/v1/session', { status: 400 } as Response, null, Error());
831
+ mockOpenmrsFetch.mockRejectedValueOnce(error);
832
+
833
+ await expect(setSessionLocale('xx')).rejects.toBe(error);
834
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(1);
835
+ });
836
+ });
837
+
838
+ describe('verifyTotpCode', () => {
839
+ beforeEach(() => {
840
+ mockOpenmrsFetch.mockClear();
841
+ });
842
+
843
+ it('should send the code and ask the server to remember the device only when requested', async () => {
844
+ mockOpenmrsFetch.mockResolvedValue(createMockFetchResponse({ authenticated: false, sessionId: '' }));
845
+
846
+ await verifyTotpCode('123456');
847
+ expect(mockOpenmrsFetch).toHaveBeenLastCalledWith('/ws/rest/v1/session', {
848
+ headers: { 'X-Totp-Code': '123456' },
849
+ });
850
+
851
+ await verifyTotpCode('123456', true);
852
+ expect(mockOpenmrsFetch).toHaveBeenLastCalledWith('/ws/rest/v1/session?rememberMe=true', {
853
+ headers: { 'X-Totp-Code': '123456' },
854
+ });
855
+ });
856
+
857
+ it('should refetch and resolve with the session when the code is accepted', async () => {
858
+ const mockSession: Session = { authenticated: true, sessionId: 'test-session', user: {} as LoggedInUser };
859
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse({ authenticated: true, sessionId: 'test-session' }));
860
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse(mockSession));
861
+
862
+ await expect(verifyTotpCode('123456')).resolves.toEqual(mockSession);
863
+ expect(sessionStore.getState()).toEqual({ loaded: true, session: mockSession });
864
+ });
865
+
866
+ it('should resolve with the unauthenticated session without refetching when the code is not accepted', async () => {
867
+ const unauthenticated = { authenticated: false, sessionId: '' };
868
+ mockOpenmrsFetch.mockResolvedValueOnce(createMockFetchResponse(unauthenticated));
869
+
870
+ await expect(verifyTotpCode('000000')).resolves.toEqual(unauthenticated);
871
+ expect(mockOpenmrsFetch).toHaveBeenCalledTimes(1);
872
+ });
873
+ });
874
+
875
+ describe('logout', () => {
876
+ const loggedOut = { loaded: true, session: { authenticated: false, sessionId: '' } };
877
+
878
+ beforeEach(() => {
879
+ sessionStore.setState(
880
+ {
881
+ loaded: true,
882
+ session: { authenticated: true, sessionId: 'test-session', user: {} as LoggedInUser },
883
+ },
884
+ true,
885
+ );
886
+ mockOpenmrsFetch.mockReset();
887
+ });
888
+
889
+ it('should delete the session and record a logged-out session', async () => {
890
+ mockOpenmrsFetch
891
+ .mockResolvedValueOnce(createMockFetchResponse(null))
892
+ .mockResolvedValueOnce(createMockFetchResponse({ authenticated: false, sessionId: '' }));
893
+
894
+ await logout();
895
+
896
+ expect(mockOpenmrsFetch).toHaveBeenNthCalledWith(1, '/ws/rest/v1/session', {
897
+ method: 'DELETE',
898
+ rejectAuthFailure: true,
899
+ });
900
+ expect(sessionStore.getState()).toEqual(loggedOut);
901
+ });
902
+
903
+ it('should treat a session the server has already ended as logged out', async () => {
904
+ mockOpenmrsFetch.mockRejectedValue(createAuthFailure(401));
905
+
906
+ await expect(logout()).resolves.toBeUndefined();
907
+ expect(sessionStore.getState()).toEqual(loggedOut);
908
+ });
909
+
910
+ it('should reject and keep the session when the server fails to end it', async () => {
911
+ const before = sessionStore.getState();
912
+ const error = new OpenmrsFetchError('/openmrs/ws/rest/v1/session', { status: 500 } as Response, null, Error());
913
+ mockOpenmrsFetch.mockRejectedValueOnce(error);
914
+
915
+ await expect(logout()).rejects.toBe(error);
916
+ expect(sessionStore.getState()).toEqual(before);
917
+ });
918
+ });
@@ -2,7 +2,7 @@
2
2
  import { reportError } from '@openmrs/esm-error-handling';
3
3
  import { createGlobalStore } from '@openmrs/esm-state';
4
4
  import { isUndefined } from 'lodash-es';
5
- import { openmrsFetch, restBaseUrl, sessionEndpoint } from './openmrs-fetch';
5
+ import { OpenmrsFetchError, openmrsFetch, restBaseUrl, sessionEndpoint } from './openmrs-fetch';
6
6
  import type { LoggedInUser, SessionLocation, Privilege, Role, Session, FetchResponse } from './types';
7
7
 
8
8
  export type SessionStore = LoadedSessionStore | UnloadedSessionStore;
@@ -15,6 +15,8 @@ export type LoadedSessionStore = {
15
15
  export type UnloadedSessionStore = {
16
16
  loaded: false;
17
17
  session: null;
18
+ /** Set when fetching the session failed before any session had loaded. */
19
+ error?: Error;
18
20
  };
19
21
 
20
22
  /** @internal */
@@ -62,7 +64,8 @@ function refreshSessionIfStale(): Promise<SessionStore> | null {
62
64
  * current user's session. If the session hasn't been loaded, was loaded more than
63
65
  * a minute ago, or is in the middle of being refetched, the Promise waits for the
64
66
  * fetch in question rather than resolving with data that may be out of date. The
65
- * session it resolves with is therefore never more than a minute old.
67
+ * session it resolves with is therefore never more than a minute old, unless that fetch fails,
68
+ * in which case it resolves with the last session that loaded.
66
69
  *
67
70
  * The function accepts an optional `opts` object with an `includeAuthStatus` boolean
68
71
  * property that defaults to `true`. When `true`, the entire {@link Session} object
@@ -104,17 +107,23 @@ function getCurrentUser(opts?: { includeAuthStatus?: boolean }): Promise<Session
104
107
  const includeAuthStatus = opts?.includeAuthStatus ?? true;
105
108
  const select = (session: Session) => (includeAuthStatus ? session : (session.user as LoggedInUser));
106
109
 
107
- if (!refreshSessionIfStale()) {
110
+ const refresh = refreshSessionIfStale();
111
+ if (!refresh) {
108
112
  return Promise.resolve(select((sessionStore.getState() as LoadedSessionStore).session));
109
113
  }
110
114
 
111
115
  return new Promise<Session | LoggedInUser>((resolve) => {
112
- const unsubscribe = sessionStore.subscribe((state) => {
116
+ const resolveIfLoaded = () => {
117
+ const state = sessionStore.getState();
113
118
  if (state.loaded) {
114
119
  unsubscribe();
115
120
  resolve(select(state.session));
116
121
  }
117
- });
122
+ };
123
+ const unsubscribe = sessionStore.subscribe(resolveIfLoaded);
124
+ // A failed refresh keeps an already-loaded session without updating the store, so the
125
+ // subscription alone would never fire.
126
+ refresh.then(resolveIfLoaded, resolveIfLoaded);
118
127
  });
119
128
  }
120
129
 
@@ -212,7 +221,12 @@ function isSuperUser(user: { roles: Array<Role> }) {
212
221
  * the user. All subscribers to the session store will be notified of the
213
222
  * new user once the new version of the user object is downloaded.
214
223
  *
215
- * @returns A Promise resolving to the updated session store state.
224
+ * If the server rejects the request as unauthenticated (401 or 403), the store records a
225
+ * logged-out session. If the request fails for any other reason, a session that has already
226
+ * loaded is kept; otherwise the store records the error.
227
+ *
228
+ * @returns A Promise resolving to the updated session store state. It rejects with the
229
+ * store state if the request fails for a reason other than authentication.
216
230
  *
217
231
  * @example
218
232
  * ```js
@@ -230,6 +244,7 @@ export function refetchCurrentUser(username?: string, password?: string) {
230
244
  const refresh = handleSessionResponse(
231
245
  openmrsFetch(sessionEndpoint, {
232
246
  headers,
247
+ rejectAuthFailure: true,
233
248
  }),
234
249
  );
235
250
 
@@ -259,10 +274,13 @@ export function refetchCurrentUser(username?: string, password?: string) {
259
274
  * ```
260
275
  */
261
276
  export function clearCurrentUser() {
262
- sessionStore.setState({
263
- loaded: true,
264
- session: { authenticated: false, sessionId: '' },
265
- });
277
+ sessionStore.setState(
278
+ {
279
+ loaded: true,
280
+ session: { authenticated: false, sessionId: '' },
281
+ },
282
+ true,
283
+ );
266
284
  }
267
285
 
268
286
  /**
@@ -375,7 +393,7 @@ export async function getSessionLocation(): Promise<SessionLocation | undefined>
375
393
  * await setSessionLocation('location-uuid-here', abortController);
376
394
  * ```
377
395
  */
378
- export async function setSessionLocation(locationUuid: string, abortController: AbortController): Promise<any> {
396
+ export async function setSessionLocation(locationUuid: string, abortController?: AbortController): Promise<any> {
379
397
  return handleSessionResponse(
380
398
  openmrsFetch(sessionEndpoint, {
381
399
  method: 'POST',
@@ -383,11 +401,42 @@ export async function setSessionLocation(locationUuid: string, abortController:
383
401
  headers: {
384
402
  'Content-Type': 'application/json',
385
403
  },
386
- signal: abortController.signal,
404
+ rejectAuthFailure: true,
405
+ signal: abortController?.signal,
387
406
  }),
388
407
  );
389
408
  }
390
409
 
410
+ /**
411
+ * Sets the locale for the current session only, leaving the user's default locale unchanged.
412
+ * Use {@link setUserProperties} with a `defaultLocale` property to change the default instead.
413
+ *
414
+ * @param locale The locale in Java's `Locale#toString()` form, e.g. `en_GB`.
415
+ * @param abortController Optional AbortController to allow cancellation of the request.
416
+ * @returns A Promise that resolves with the updated SessionStore after refetching the current user.
417
+ * It rejects with an {@link OpenmrsFetchError} if the server does not accept the locale.
418
+ *
419
+ * @example
420
+ * ```ts
421
+ * import { setSessionLocale } from '@openmrs/esm-api';
422
+ * await setSessionLocale('fr');
423
+ * ```
424
+ *
425
+ * @internal
426
+ */
427
+ export async function setSessionLocale(locale: string, abortController?: AbortController): Promise<SessionStore> {
428
+ await openmrsFetch(sessionEndpoint, {
429
+ method: 'POST',
430
+ body: { locale },
431
+ headers: {
432
+ 'Content-Type': 'application/json',
433
+ },
434
+ signal: abortController?.signal,
435
+ });
436
+
437
+ return refetchCurrentUser();
438
+ }
439
+
391
440
  /**
392
441
  * Updates the user properties for a specific user. User properties are key-value
393
442
  * pairs that store user-specific settings and preferences. After updating the
@@ -433,26 +482,126 @@ export async function setUserProperties(
433
482
  return refetchCurrentUser();
434
483
  }
435
484
 
485
+ /**
486
+ * Submits a TOTP code to complete a two-factor login challenge. When the code is accepted, the
487
+ * session store is refreshed with the now-authenticated session.
488
+ *
489
+ * @param code The code from the user's authenticator app.
490
+ * @param rememberDevice Whether the server should skip the challenge on this device in future.
491
+ * @returns A Promise resolving to the session. Its `authenticated` property is `false` if the
492
+ * code was not accepted. It rejects with an {@link OpenmrsFetchError} if the server rejects the
493
+ * request, in which case `responseBody` may explain why.
494
+ *
495
+ * @example
496
+ * ```ts
497
+ * import { verifyTotpCode } from '@openmrs/esm-api';
498
+ * const session = await verifyTotpCode('123456', true);
499
+ * ```
500
+ *
501
+ * @internal
502
+ */
503
+ export async function verifyTotpCode(code: string, rememberDevice = false): Promise<Session> {
504
+ const response = await openmrsFetch<Session>(
505
+ rememberDevice ? `${sessionEndpoint}?rememberMe=true` : sessionEndpoint,
506
+ {
507
+ headers: {
508
+ 'X-Totp-Code': code,
509
+ },
510
+ },
511
+ );
512
+
513
+ if (response.data?.authenticated) {
514
+ // `refetchCurrentUser` only resolves once the store holds a session.
515
+ const store = (await refetchCurrentUser()) as LoadedSessionStore;
516
+ return store.session;
517
+ }
518
+
519
+ return response.data;
520
+ }
521
+
522
+ /**
523
+ * Ends the current session on the server and records a logged-out session in the session store.
524
+ * A server that has already ended the session (401 or 403) counts as a successful logout.
525
+ *
526
+ * Other data cached for the logged-in user, such as SWR caches, is the caller's to clear.
527
+ *
528
+ * @returns A Promise that resolves once the session store holds a logged-out session.
529
+ *
530
+ * @example
531
+ * ```ts
532
+ * import { logout } from '@openmrs/esm-api';
533
+ * await logout();
534
+ * ```
535
+ *
536
+ * @internal
537
+ */
538
+ export async function logout(): Promise<void> {
539
+ try {
540
+ await openmrsFetch(sessionEndpoint, { method: 'DELETE', rejectAuthFailure: true });
541
+ } catch (err) {
542
+ if (!isAuthFailure(err)) {
543
+ throw err;
544
+ }
545
+ }
546
+
547
+ clearCurrentUser();
548
+ // The session just cleared is already correct, so a failed refresh loses nothing.
549
+ await refetchCurrentUser().catch(() => {});
550
+ }
551
+
436
552
  function handleSessionResponse(result: Promise<FetchResponse<Session>>) {
437
553
  return new Promise<SessionStore>((resolve, reject) => {
438
554
  result
439
555
  .then((res) => {
440
- let nextState: SessionStore;
441
556
  if (typeof res?.data === 'object') {
442
- nextState = { loaded: true, session: res.data };
443
- sessionStore.setState(nextState);
557
+ const nextState: SessionStore = { loaded: true, session: res.data };
558
+ sessionStore.setState(nextState, true);
444
559
  resolve(nextState);
445
560
  } else {
446
- nextState = { loaded: false, session: null };
447
- sessionStore.setState(nextState);
448
- reject(nextState);
561
+ reject(recordSessionFailure(Error('The session endpoint did not respond with a session')));
449
562
  }
450
563
  })
451
564
  .catch((err) => {
565
+ // An auth failure is the session endpoint's answer for "not logged in", not a failed lookup.
566
+ if (isAuthFailure(err)) {
567
+ if (isUnauthenticatedSession(err.responseBody)) {
568
+ sessionStore.setState({ loaded: true, session: err.responseBody }, true);
569
+ } else {
570
+ clearCurrentUser();
571
+ }
572
+ resolve(sessionStore.getState());
573
+ return;
574
+ }
575
+
452
576
  reportError(`Failed to fetch new session information: ${err}`);
453
- const nextState: SessionStore = { loaded: false, session: null };
454
- sessionStore.setState(nextState);
455
- reject(nextState);
577
+ reject(recordSessionFailure(err));
456
578
  });
457
579
  });
458
580
  }
581
+
582
+ function isAuthFailure(err: unknown): err is OpenmrsFetchError {
583
+ return err instanceof OpenmrsFetchError && (err.response.status === 401 || err.response.status === 403);
584
+ }
585
+
586
+ function isUnauthenticatedSession(body: unknown): body is Session {
587
+ return typeof body === 'object' && body !== null && (body as Session).authenticated === false;
588
+ }
589
+
590
+ /**
591
+ * A session that has already loaded is kept when a later fetch fails, since it is still the best answer
592
+ * available. With nothing to fall back on, the error is recorded so the app shell can show an error page.
593
+ */
594
+ function recordSessionFailure(err: unknown): SessionStore {
595
+ if (!sessionStore.getState().loaded) {
596
+ sessionStore.setState(
597
+ {
598
+ loaded: false,
599
+ session: null,
600
+ error: err instanceof Error ? err : Error(String(err)),
601
+ },
602
+ true,
603
+ );
604
+ }
605
+
606
+ return sessionStore.getState();
607
+ }
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { getConfig } from '@openmrs/esm-config';
3
3
  import { navigate } from '@openmrs/esm-navigation';
4
- import { openmrsFetch } from './openmrs-fetch';
4
+ import { OpenmrsFetchError, openmrsFetch } from './openmrs-fetch';
5
5
 
6
6
  vi.mock('@openmrs/esm-navigation', () => ({
7
7
  clearHistory: vi.fn(),
@@ -231,4 +231,14 @@ describe('openmrsFetch', () => {
231
231
  to: '/openmrs/spa/login',
232
232
  });
233
233
  });
234
+
235
+ it('navigates to login and rejects on a 401 when rejectAuthFailure is set', async () => {
236
+ mockFetch.mockResolvedValue(new Response('', { status: 401, statusText: 'Unauthorized' }));
237
+
238
+ const result = openmrsFetch('/ws/rest/v1/session', { rejectAuthFailure: true });
239
+
240
+ await expect(result).rejects.toBeInstanceOf(OpenmrsFetchError);
241
+ await expect(result).rejects.toMatchObject({ response: { status: 401 } });
242
+ expect(mockNavigate).toHaveBeenCalledWith({ to: '${openmrsSpaBase}/login' });
243
+ });
234
244
  });