@openmrs/esm-api 10.0.1-pre.5607 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/current-user.d.ts +65 -3
- package/dist/current-user.d.ts.map +1 -1
- package/dist/current-user.js +146 -23
- package/dist/openmrs-fetch.d.ts +5 -0
- package/dist/openmrs-fetch.d.ts.map +1 -1
- package/dist/openmrs-fetch.js +53 -57
- package/dist/public.d.ts +1 -1
- package/dist/public.d.ts.map +1 -1
- package/dist/public.js +1 -1
- package/mock-jest.ts +3 -0
- package/mock.ts +3 -0
- package/package.json +9 -9
- package/src/current-user.test.ts +230 -25
- package/src/current-user.ts +170 -21
- package/src/openmrs-fetch.test.ts +11 -1
- package/src/openmrs-fetch.ts +62 -70
- package/src/public.ts +12 -1
package/src/current-user.test.ts
CHANGED
|
@@ -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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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).
|
|
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({}))
|
|
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).
|
|
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({}))
|
|
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).
|
|
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
|
+
});
|
package/src/current-user.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|