richie-education 3.2.2-dev45 → 3.2.2-dev52
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/js/api/auth/keycloak.spec.ts +63 -2
- package/js/api/auth/keycloak.ts +40 -2
- package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular.tsx +85 -4
- package/js/components/SaleTunnel/_styles.scss +8 -0
- package/js/components/SaleTunnel/index.spec.tsx +118 -0
- package/js/hooks/useOpenEdxProfile/index.ts +4 -2
- package/js/index.tsx +1 -1
- package/js/types/api.ts +14 -5
- package/js/types/keycloak.ts +8 -0
- package/js/widgets/index.tsx +3 -2
- package/package.json +1 -1
- package/scss/colors/_theme.scss +1 -0
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
2
|
+
import { KeycloakAccountApi } from 'types/api';
|
|
2
3
|
import API from './keycloak';
|
|
3
4
|
|
|
4
5
|
const mockKeycloakInit = jest.fn().mockResolvedValue(true);
|
|
5
6
|
const mockKeycloakLogout = jest.fn().mockResolvedValue(undefined);
|
|
6
7
|
const mockKeycloakLogin = jest.fn().mockResolvedValue(undefined);
|
|
7
8
|
const mockKeycloakLoadUserProfile = jest.fn();
|
|
9
|
+
const mockKeycloakUpdateToken = jest.fn().mockResolvedValue(true);
|
|
10
|
+
const mockKeycloakCreateAccountUrl = jest
|
|
11
|
+
.fn()
|
|
12
|
+
.mockReturnValue('https://keycloak.test/auth/realms/richie-realm/account');
|
|
13
|
+
const mockIdToken = 'mock-id-token-12345';
|
|
14
|
+
const mockIdTokenParsed = {
|
|
15
|
+
preferred_username: 'johndoe',
|
|
16
|
+
firstName: 'John',
|
|
17
|
+
lastName: 'Doe',
|
|
18
|
+
email: 'johndoe@example.com',
|
|
19
|
+
};
|
|
8
20
|
|
|
9
21
|
jest.mock('keycloak-js', () => {
|
|
10
22
|
return jest.fn().mockImplementation(() => ({
|
|
@@ -12,6 +24,10 @@ jest.mock('keycloak-js', () => {
|
|
|
12
24
|
logout: mockKeycloakLogout,
|
|
13
25
|
login: mockKeycloakLogin,
|
|
14
26
|
loadUserProfile: mockKeycloakLoadUserProfile,
|
|
27
|
+
updateToken: mockKeycloakUpdateToken,
|
|
28
|
+
createAccountUrl: mockKeycloakCreateAccountUrl,
|
|
29
|
+
idToken: mockIdToken,
|
|
30
|
+
idTokenParsed: mockIdTokenParsed,
|
|
15
31
|
}));
|
|
16
32
|
});
|
|
17
33
|
|
|
@@ -50,17 +66,40 @@ describe('Keycloak API', () => {
|
|
|
50
66
|
|
|
51
67
|
beforeEach(() => {
|
|
52
68
|
jest.clearAllMocks();
|
|
69
|
+
sessionStorage.clear();
|
|
53
70
|
keycloakApi = API(authConfig);
|
|
54
71
|
});
|
|
55
72
|
|
|
73
|
+
describe('user.accessToken', () => {
|
|
74
|
+
it('returns null when no token is stored', () => {
|
|
75
|
+
const token = keycloakApi.user.accessToken!();
|
|
76
|
+
expect(token).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns the token from sessionStorage', () => {
|
|
80
|
+
sessionStorage.setItem('RICHIE_USER_TOKEN', mockIdToken);
|
|
81
|
+
const token = keycloakApi.user.accessToken!();
|
|
82
|
+
expect(token).toEqual(mockIdToken);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
56
86
|
describe('user.me', () => {
|
|
87
|
+
it('returns null when updateToken fails', async () => {
|
|
88
|
+
mockKeycloakUpdateToken.mockRejectedValueOnce(new Error('Token refresh failed'));
|
|
89
|
+
const response = await keycloakApi.user.me();
|
|
90
|
+
expect(response).toBeNull();
|
|
91
|
+
expect(mockKeycloakLoadUserProfile).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
57
94
|
it('returns null when loadUserProfile fails', async () => {
|
|
95
|
+
mockKeycloakUpdateToken.mockResolvedValueOnce(true);
|
|
58
96
|
mockKeycloakLoadUserProfile.mockRejectedValueOnce(new Error('Not authenticated'));
|
|
59
97
|
const response = await keycloakApi.user.me();
|
|
60
98
|
expect(response).toBeNull();
|
|
61
99
|
});
|
|
62
100
|
|
|
63
101
|
it('returns user when loadUserProfile succeeds', async () => {
|
|
102
|
+
mockKeycloakUpdateToken.mockResolvedValueOnce(true);
|
|
64
103
|
mockKeycloakLoadUserProfile.mockResolvedValueOnce({
|
|
65
104
|
firstName: 'John',
|
|
66
105
|
lastName: 'Doe',
|
|
@@ -68,10 +107,13 @@ describe('Keycloak API', () => {
|
|
|
68
107
|
});
|
|
69
108
|
|
|
70
109
|
const response = await keycloakApi.user.me();
|
|
110
|
+
expect(mockKeycloakUpdateToken).toHaveBeenCalledWith(30);
|
|
71
111
|
expect(response).toEqual({
|
|
72
112
|
username: 'John Doe',
|
|
73
113
|
email: 'johndoe@example.com',
|
|
114
|
+
access_token: mockIdToken,
|
|
74
115
|
});
|
|
116
|
+
expect(sessionStorage.getItem('RICHIE_USER_TOKEN')).toEqual(mockIdToken);
|
|
75
117
|
});
|
|
76
118
|
});
|
|
77
119
|
|
|
@@ -106,6 +148,24 @@ describe('Keycloak API', () => {
|
|
|
106
148
|
});
|
|
107
149
|
});
|
|
108
150
|
|
|
151
|
+
describe('user.account', () => {
|
|
152
|
+
it('returns profile data from idTokenParsed via account.get()', () => {
|
|
153
|
+
const profile = (keycloakApi.user.account as KeycloakAccountApi).get();
|
|
154
|
+
expect(profile).toEqual({
|
|
155
|
+
username: 'johndoe',
|
|
156
|
+
firstName: 'John',
|
|
157
|
+
lastName: 'Doe',
|
|
158
|
+
email: 'johndoe@example.com',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('returns the account management URL via account.updateUrl()', () => {
|
|
163
|
+
const url = (keycloakApi.user.account as any).updateUrl();
|
|
164
|
+
expect(url).toBe('https://keycloak.test/auth/realms/richie-realm/account');
|
|
165
|
+
expect(mockKeycloakCreateAccountUrl).toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
109
169
|
describe('Keycloak initialization', () => {
|
|
110
170
|
it('initializes keycloak with correct configuration', () => {
|
|
111
171
|
const Keycloak = require('keycloak-js');
|
|
@@ -118,8 +178,9 @@ describe('Keycloak API', () => {
|
|
|
118
178
|
|
|
119
179
|
expect(mockKeycloakInit).toHaveBeenCalledWith({
|
|
120
180
|
checkLoginIframe: false,
|
|
121
|
-
flow: '
|
|
122
|
-
|
|
181
|
+
flow: 'standard',
|
|
182
|
+
onLoad: 'check-sso',
|
|
183
|
+
pkceMethod: 'S256',
|
|
123
184
|
});
|
|
124
185
|
});
|
|
125
186
|
});
|
package/js/api/auth/keycloak.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import Keycloak from 'keycloak-js';
|
|
2
2
|
import { AuthenticationBackend } from 'types/commonDataProps';
|
|
3
3
|
import { APIAuthentication } from 'types/api';
|
|
4
|
+
import { KeycloakApiProfile } from 'types/keycloak';
|
|
4
5
|
import { location } from 'utils/indirection/window';
|
|
5
6
|
import { handle } from 'utils/errors/handle';
|
|
7
|
+
import { RICHIE_USER_TOKEN } from 'settings';
|
|
6
8
|
|
|
7
9
|
const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
|
|
8
10
|
const keycloak = new Keycloak({
|
|
@@ -12,23 +14,46 @@ const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
|
|
|
12
14
|
});
|
|
13
15
|
keycloak.init({
|
|
14
16
|
checkLoginIframe: false,
|
|
15
|
-
flow: '
|
|
16
|
-
|
|
17
|
+
flow: 'standard',
|
|
18
|
+
onLoad: 'check-sso',
|
|
19
|
+
pkceMethod: 'S256',
|
|
17
20
|
});
|
|
18
21
|
|
|
22
|
+
keycloak.onTokenExpired = () => {
|
|
23
|
+
keycloak.updateToken(30).catch(() => {
|
|
24
|
+
sessionStorage.removeItem(RICHIE_USER_TOKEN);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
keycloak.onAuthRefreshSuccess = () => {
|
|
29
|
+
if (keycloak.idToken) {
|
|
30
|
+
sessionStorage.setItem(RICHIE_USER_TOKEN, keycloak.idToken);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
19
34
|
const getRedirectUri = () => {
|
|
20
35
|
return `${location.origin}${location.pathname}`;
|
|
21
36
|
};
|
|
22
37
|
|
|
23
38
|
return {
|
|
24
39
|
user: {
|
|
40
|
+
accessToken: () => sessionStorage.getItem(RICHIE_USER_TOKEN),
|
|
25
41
|
me: async () => {
|
|
42
|
+
try {
|
|
43
|
+
await keycloak.updateToken(30);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
handle(error);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
return keycloak
|
|
27
50
|
.loadUserProfile()
|
|
28
51
|
.then((userProfile) => {
|
|
52
|
+
sessionStorage.setItem(RICHIE_USER_TOKEN, keycloak.idToken!);
|
|
29
53
|
return {
|
|
30
54
|
username: `${userProfile.firstName} ${userProfile.lastName}`,
|
|
31
55
|
email: userProfile.email,
|
|
56
|
+
access_token: keycloak.idToken,
|
|
32
57
|
};
|
|
33
58
|
})
|
|
34
59
|
.catch((error) => {
|
|
@@ -46,8 +71,21 @@ const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
|
|
|
46
71
|
},
|
|
47
72
|
|
|
48
73
|
logout: async () => {
|
|
74
|
+
sessionStorage.removeItem(RICHIE_USER_TOKEN);
|
|
49
75
|
await keycloak.logout({ redirectUri: getRedirectUri() });
|
|
50
76
|
},
|
|
77
|
+
|
|
78
|
+
account: {
|
|
79
|
+
get: (): KeycloakApiProfile => {
|
|
80
|
+
return {
|
|
81
|
+
username: keycloak.idTokenParsed?.preferred_username,
|
|
82
|
+
firstName: keycloak.idTokenParsed?.firstName,
|
|
83
|
+
lastName: keycloak.idTokenParsed?.lastName,
|
|
84
|
+
email: keycloak.idTokenParsed?.email,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
updateUrl: () => keycloak.createAccountUrl(),
|
|
88
|
+
},
|
|
51
89
|
},
|
|
52
90
|
};
|
|
53
91
|
};
|
|
@@ -11,6 +11,9 @@ import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
|
|
|
11
11
|
import { PaymentSchedule, ProductType } from 'types/Joanie';
|
|
12
12
|
import { usePaymentPlan } from 'hooks/usePaymentPlan';
|
|
13
13
|
import { HttpError } from 'utils/errors/HttpError';
|
|
14
|
+
import { APIBackend, KeycloakAccountApi } from 'types/api';
|
|
15
|
+
import context from 'utils/context';
|
|
16
|
+
import { AuthenticationApi } from 'api/authentication';
|
|
14
17
|
|
|
15
18
|
const messages = defineMessages({
|
|
16
19
|
title: {
|
|
@@ -49,6 +52,31 @@ const messages = defineMessages({
|
|
|
49
52
|
defaultMessage:
|
|
50
53
|
'This email will be used to send you confirmation mails, it is the one you created your account with.',
|
|
51
54
|
},
|
|
55
|
+
keycloakUsernameLabel: {
|
|
56
|
+
id: 'components.SaleTunnel.Information.keycloak.account.label',
|
|
57
|
+
description: 'Label for the name',
|
|
58
|
+
defaultMessage: 'Account name',
|
|
59
|
+
},
|
|
60
|
+
keycloakUsernameInfo: {
|
|
61
|
+
id: 'components.SaleTunnel.Information.keycloak.account.info',
|
|
62
|
+
description: 'Info for the name',
|
|
63
|
+
defaultMessage: 'This name will be used in legal documents.',
|
|
64
|
+
},
|
|
65
|
+
keycloakEmailInfo: {
|
|
66
|
+
id: 'components.SaleTunnel.Information.keycloak.email.info',
|
|
67
|
+
description: 'Info for the email',
|
|
68
|
+
defaultMessage: 'This email will be used to send you confirmation mails.',
|
|
69
|
+
},
|
|
70
|
+
keycloakAccountLinkInfo: {
|
|
71
|
+
id: 'components.SaleTunnel.Information.keycloak.updateLinkInfo',
|
|
72
|
+
description: 'Text before the keycloak account update link',
|
|
73
|
+
defaultMessage: 'If any of the information above is incorrect,',
|
|
74
|
+
},
|
|
75
|
+
keycloakAccountLinkLabel: {
|
|
76
|
+
id: 'components.SaleTunnel.Information.keycloak.updateLinkLabel',
|
|
77
|
+
description: 'Label of the keycloak link to update account',
|
|
78
|
+
defaultMessage: 'please update your account',
|
|
79
|
+
},
|
|
52
80
|
voucherTitle: {
|
|
53
81
|
id: 'components.SaleTunnel.Information.voucher.title',
|
|
54
82
|
description: 'Title for the voucher',
|
|
@@ -130,6 +158,8 @@ export const SaleTunnelInformationSingular = () => {
|
|
|
130
158
|
setNeedsPayment(!fromBatchOrder);
|
|
131
159
|
}, [fromBatchOrder, setNeedsPayment]);
|
|
132
160
|
|
|
161
|
+
const isKeycloakBackend = context?.authentication.backend === APIBackend.KEYCLOAK;
|
|
162
|
+
|
|
133
163
|
return (
|
|
134
164
|
<>
|
|
135
165
|
<div>
|
|
@@ -148,11 +178,17 @@ export const SaleTunnelInformationSingular = () => {
|
|
|
148
178
|
<div className="description mb-s">
|
|
149
179
|
<FormattedMessage {...messages.description} />
|
|
150
180
|
</div>
|
|
151
|
-
<OpenEdxFullNameForm />
|
|
152
181
|
<AddressSelector />
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
182
|
+
{isKeycloakBackend ? (
|
|
183
|
+
<KeycloakAccountEdit />
|
|
184
|
+
) : (
|
|
185
|
+
<>
|
|
186
|
+
<OpenEdxFullNameForm />
|
|
187
|
+
<div className="mt-s">
|
|
188
|
+
<Email />
|
|
189
|
+
</div>
|
|
190
|
+
</>
|
|
191
|
+
)}
|
|
156
192
|
</div>
|
|
157
193
|
)}
|
|
158
194
|
<div>
|
|
@@ -163,6 +199,51 @@ export const SaleTunnelInformationSingular = () => {
|
|
|
163
199
|
);
|
|
164
200
|
};
|
|
165
201
|
|
|
202
|
+
const KeycloakAccountEdit = () => {
|
|
203
|
+
const accountApi = AuthenticationApi!.account as KeycloakAccountApi;
|
|
204
|
+
const { user } = useSession();
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<>
|
|
208
|
+
<div className="mt-s">
|
|
209
|
+
<div className="sale-tunnel__username">
|
|
210
|
+
<div className="sale-tunnel__username__top">
|
|
211
|
+
<h4>
|
|
212
|
+
<FormattedMessage {...messages.keycloakUsernameLabel} />
|
|
213
|
+
</h4>
|
|
214
|
+
<div className="fw-bold">{user?.username}</div>
|
|
215
|
+
</div>
|
|
216
|
+
<div className="sale-tunnel__username__description">
|
|
217
|
+
<FormattedMessage {...messages.keycloakUsernameInfo} />
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="mt-s">
|
|
222
|
+
<div className="sale-tunnel__email">
|
|
223
|
+
<div className="sale-tunnel__email__top">
|
|
224
|
+
<h4>
|
|
225
|
+
<FormattedMessage {...messages.emailLabel} />
|
|
226
|
+
</h4>
|
|
227
|
+
<div className="fw-bold">{user?.email}</div>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="sale-tunnel__email__description">
|
|
230
|
+
<FormattedMessage {...messages.keycloakEmailInfo} />
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="mt-s">
|
|
235
|
+
<div className="sale-tunnel__account-link">
|
|
236
|
+
<FormattedMessage {...messages.keycloakAccountLinkInfo} />{' '}
|
|
237
|
+
<a href={accountApi.updateUrl()}>
|
|
238
|
+
<FormattedMessage {...messages.keycloakAccountLinkLabel} />
|
|
239
|
+
</a>
|
|
240
|
+
.
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</>
|
|
244
|
+
);
|
|
245
|
+
};
|
|
246
|
+
|
|
166
247
|
const Email = () => {
|
|
167
248
|
const { user } = useSession();
|
|
168
249
|
const { data: openEdxProfileData } = useOpenEdxProfile({
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
&__username,
|
|
70
71
|
&__email {
|
|
71
72
|
&__top {
|
|
72
73
|
display: flex;
|
|
@@ -82,6 +83,13 @@
|
|
|
82
83
|
font-size: 0.75rem;
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
|
|
87
|
+
&__account-link {
|
|
88
|
+
a {
|
|
89
|
+
color: r-theme-val(sale-tunnel, account-link-color);
|
|
90
|
+
text-decoration: underline;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
85
93
|
.price--striked {
|
|
86
94
|
text-decoration: line-through;
|
|
87
95
|
opacity: 0.5;
|
|
@@ -36,6 +36,8 @@ import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
|
|
|
36
36
|
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
37
37
|
import { StringHelper } from 'utils/StringHelper';
|
|
38
38
|
import { DEFAULT_DATE_FORMAT } from 'hooks/useDateFormat';
|
|
39
|
+
import { AuthenticationApi } from 'api/authentication';
|
|
40
|
+
import { APIAuthentication } from 'types/api';
|
|
39
41
|
import { Deferred } from 'utils/test/deferred';
|
|
40
42
|
|
|
41
43
|
jest.mock('utils/context', () => ({
|
|
@@ -850,3 +852,119 @@ describe.each([
|
|
|
850
852
|
expect(screen.queryByText('DISCOUNT30')).not.toBeInTheDocument();
|
|
851
853
|
});
|
|
852
854
|
});
|
|
855
|
+
|
|
856
|
+
describe('SaleTunnel with Keycloak backend', () => {
|
|
857
|
+
const mockAccountUpdateUrl = 'https://keycloak.test/auth/realms/richie-realm/account';
|
|
858
|
+
const course = PacedCourseFactory().one();
|
|
859
|
+
let richieUser: User;
|
|
860
|
+
let originalBackend: string;
|
|
861
|
+
let originalAccount: APIAuthentication['account'];
|
|
862
|
+
|
|
863
|
+
const Wrapper = (props: Omit<SaleTunnelProps, 'isOpen' | 'onClose'>) => {
|
|
864
|
+
const [open, setOpen] = useState(true);
|
|
865
|
+
return <SaleTunnel {...props} course={course} isOpen={open} onClose={() => setOpen(false)} />;
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
beforeEach(() => {
|
|
869
|
+
jest.useFakeTimers();
|
|
870
|
+
jest.clearAllTimers();
|
|
871
|
+
jest.resetAllMocks();
|
|
872
|
+
|
|
873
|
+
fetchMock.restore();
|
|
874
|
+
sessionStorage.clear();
|
|
875
|
+
|
|
876
|
+
richieUser = UserFactory({
|
|
877
|
+
username: 'John Doe',
|
|
878
|
+
email: 'johndoe@example.com',
|
|
879
|
+
}).one();
|
|
880
|
+
|
|
881
|
+
// Mock OpenEdx profile endpoints that may still be triggered by the fonzie-based
|
|
882
|
+
// AuthenticationApi (resolved at module load). These should not be called by
|
|
883
|
+
// the keycloak code paths we are testing.
|
|
884
|
+
fetchMock.get(`begin:https://auth.test/api/user/v1/accounts/`, {});
|
|
885
|
+
fetchMock.get(`begin:https://auth.test/api/user/v1/preferences/`, {});
|
|
886
|
+
|
|
887
|
+
// Temporarily switch context to keycloak backend
|
|
888
|
+
const context = require('utils/context').default;
|
|
889
|
+
originalBackend = context.authentication.backend;
|
|
890
|
+
context.authentication.backend = 'keycloak';
|
|
891
|
+
|
|
892
|
+
// Add keycloak account methods to AuthenticationApi
|
|
893
|
+
originalAccount = AuthenticationApi!.account;
|
|
894
|
+
AuthenticationApi!.account = {
|
|
895
|
+
get: () => ({
|
|
896
|
+
username: 'johndoe',
|
|
897
|
+
firstName: 'John',
|
|
898
|
+
lastName: 'Doe',
|
|
899
|
+
email: 'johndoe@example.com',
|
|
900
|
+
}),
|
|
901
|
+
updateUrl: () => mockAccountUpdateUrl,
|
|
902
|
+
};
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// Must be called after beforeEach so fetchMock.restore() doesn't clear joanie mocks
|
|
906
|
+
setupJoanieSession();
|
|
907
|
+
|
|
908
|
+
afterEach(() => {
|
|
909
|
+
// Restore original backend and account
|
|
910
|
+
const context = require('utils/context').default;
|
|
911
|
+
context.authentication.backend = originalBackend;
|
|
912
|
+
AuthenticationApi!.account = originalAccount;
|
|
913
|
+
|
|
914
|
+
act(() => {
|
|
915
|
+
jest.runOnlyPendingTimers();
|
|
916
|
+
});
|
|
917
|
+
jest.useRealTimers();
|
|
918
|
+
cleanup();
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('should render keycloak account name, email, and update link instead of OpenEdx profile', async () => {
|
|
922
|
+
const product = CredentialProductFactory().one();
|
|
923
|
+
const paymentPlan = PaymentPlanFactory().one();
|
|
924
|
+
|
|
925
|
+
fetchMock
|
|
926
|
+
.get(
|
|
927
|
+
`https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify({
|
|
928
|
+
course_code: course.code,
|
|
929
|
+
product_id: product.id,
|
|
930
|
+
state: NOT_CANCELED_ORDER_STATES,
|
|
931
|
+
})}`,
|
|
932
|
+
[],
|
|
933
|
+
)
|
|
934
|
+
.get(
|
|
935
|
+
`https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
|
|
936
|
+
paymentPlan,
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
|
|
940
|
+
queryOptions: { client: createTestQueryClient({ user: richieUser }) },
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// Should display the "Account name" heading
|
|
944
|
+
await screen.findByRole('heading', { level: 4, name: 'Account name' });
|
|
945
|
+
|
|
946
|
+
// Should display the username from the session
|
|
947
|
+
screen.getByText(richieUser.username);
|
|
948
|
+
|
|
949
|
+
// Should display the username info
|
|
950
|
+
screen.getByText('This name will be used in legal documents.');
|
|
951
|
+
|
|
952
|
+
// Should display the email from the session
|
|
953
|
+
screen.getByText(richieUser.email!);
|
|
954
|
+
|
|
955
|
+
// Should display the email info
|
|
956
|
+
screen.getByText('This email will be used to send you confirmation mails.');
|
|
957
|
+
|
|
958
|
+
// Should display the keycloak account update link (only the link part)
|
|
959
|
+
const updateLink = screen.getByRole('link', {
|
|
960
|
+
name: 'please update your account',
|
|
961
|
+
});
|
|
962
|
+
expect(updateLink).toHaveAttribute('href', mockAccountUpdateUrl);
|
|
963
|
+
|
|
964
|
+
// Should NOT render the OpenEdx full name form
|
|
965
|
+
expect(screen.queryByLabelText('First name and last name')).not.toBeInTheDocument();
|
|
966
|
+
|
|
967
|
+
// No OpenEdx profile API calls should have been made
|
|
968
|
+
expect(fetchMock.calls().filter(([url]) => url.includes('/api/user/v1/'))).toHaveLength(0);
|
|
969
|
+
});
|
|
970
|
+
});
|
|
@@ -7,6 +7,7 @@ import { useSessionMutation } from 'utils/react-query/useSessionMutation';
|
|
|
7
7
|
import { OpenEdxFullNameFormValues } from 'components/OpenEdxFullNameForm';
|
|
8
8
|
import { HttpError } from 'utils/errors/HttpError';
|
|
9
9
|
import { TSessionQueryKey } from 'utils/react-query/useSessionKey';
|
|
10
|
+
import { OpenEdxAccountApi } from 'types/api';
|
|
10
11
|
import { OpenEdxProfile, parseOpenEdxApiProfile } from './utils';
|
|
11
12
|
|
|
12
13
|
const messages = defineMessages({
|
|
@@ -61,7 +62,8 @@ const useOpenEdxProfile = (
|
|
|
61
62
|
|
|
62
63
|
const queryFn: () => Promise<OpenEdxProfile> = useCallback(async () => {
|
|
63
64
|
try {
|
|
64
|
-
const
|
|
65
|
+
const account = AuthenticationApi!.account as OpenEdxAccountApi;
|
|
66
|
+
const openEdxApiProfile = await account.get(username);
|
|
65
67
|
return parseOpenEdxApiProfile(intl, openEdxApiProfile);
|
|
66
68
|
} catch {
|
|
67
69
|
setError(intl.formatMessage(messages.errorGet));
|
|
@@ -79,7 +81,7 @@ const useOpenEdxProfile = (
|
|
|
79
81
|
const writeHandlers = {
|
|
80
82
|
update: mutation({
|
|
81
83
|
mutationFn: (data: OpenEdxFullNameFormValues) =>
|
|
82
|
-
AuthenticationApi!.account
|
|
84
|
+
(AuthenticationApi!.account as OpenEdxAccountApi).update(username, data),
|
|
83
85
|
onSuccess,
|
|
84
86
|
onError: () => setError(intl.formatMessage(messages.errorUpdate)),
|
|
85
87
|
}),
|
package/js/index.tsx
CHANGED
|
@@ -119,7 +119,7 @@ async function render() {
|
|
|
119
119
|
<QueryClientProvider client={queryClient}>
|
|
120
120
|
<ReactQueryDevtools initialIsOpen={false} />
|
|
121
121
|
<IntlProvider locale={locale} messages={translatedMessages} defaultLocale="en-US">
|
|
122
|
-
<Root richieReactSpots={richieReactSpots} />
|
|
122
|
+
<Root richieReactSpots={richieReactSpots} locale={locale} />
|
|
123
123
|
</IntlProvider>
|
|
124
124
|
</QueryClientProvider>,
|
|
125
125
|
);
|
package/js/types/api.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Maybe, Nullable } from 'types/utils';
|
|
|
2
2
|
import { User } from 'types/User';
|
|
3
3
|
import { UnknownEnrollment } from 'types';
|
|
4
4
|
import { OpenEdxFullNameFormValues } from 'components/OpenEdxFullNameForm';
|
|
5
|
+
import { KeycloakApiProfile } from './keycloak';
|
|
5
6
|
import { OpenEdxApiProfile } from './openEdx';
|
|
6
7
|
|
|
7
8
|
export interface APIListRequestParams {
|
|
@@ -16,17 +17,25 @@ export interface APIResponseListMeta {
|
|
|
16
17
|
offset: number;
|
|
17
18
|
total_count: number;
|
|
18
19
|
}
|
|
20
|
+
|
|
21
|
+
export interface OpenEdxAccountApi {
|
|
22
|
+
get: (username: string) => Promise<OpenEdxApiProfile>;
|
|
23
|
+
update: (username: string, values: OpenEdxFullNameFormValues) => Promise<OpenEdxApiProfile>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface KeycloakAccountApi {
|
|
27
|
+
get: () => KeycloakApiProfile;
|
|
28
|
+
updateUrl: () => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
export interface APIAuthentication {
|
|
20
32
|
login: () => void;
|
|
21
33
|
logout: () => Promise<void>;
|
|
22
34
|
me: () => Promise<Nullable<User>>;
|
|
23
35
|
register: () => void;
|
|
24
|
-
// routes below are only defined for fonzie auth
|
|
36
|
+
// routes below are only defined for fonzie and keycloak auth backends
|
|
25
37
|
accessToken?: () => Nullable<string>;
|
|
26
|
-
account?:
|
|
27
|
-
get: (username: string) => Promise<OpenEdxApiProfile>;
|
|
28
|
-
update: (username: string, values: OpenEdxFullNameFormValues) => Promise<OpenEdxApiProfile>;
|
|
29
|
-
};
|
|
38
|
+
account?: OpenEdxAccountApi | KeycloakAccountApi;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export interface APIEnrollment {
|
package/js/widgets/index.tsx
CHANGED
|
@@ -56,9 +56,10 @@ function isComponentName(
|
|
|
56
56
|
|
|
57
57
|
interface RootProps {
|
|
58
58
|
richieReactSpots: Element[];
|
|
59
|
+
locale?: string;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
export const Root = ({ richieReactSpots }: RootProps) => {
|
|
62
|
+
export const Root = ({ richieReactSpots, locale = 'en-US' }: RootProps) => {
|
|
62
63
|
const portals = richieReactSpots.map((element: Element) => {
|
|
63
64
|
// Generate a component name. It should be a key of the componentLibrary object / ComponentLibrary interface
|
|
64
65
|
const componentName = startCase(
|
|
@@ -104,7 +105,7 @@ export const Root = ({ richieReactSpots }: RootProps) => {
|
|
|
104
105
|
});
|
|
105
106
|
|
|
106
107
|
return (
|
|
107
|
-
<CunninghamProvider>
|
|
108
|
+
<CunninghamProvider currentLocale={locale}>
|
|
108
109
|
<SessionProvider>
|
|
109
110
|
<HistoryProvider>
|
|
110
111
|
<Suspense fallback={<Spinner />}>{portals}</Suspense>
|
package/package.json
CHANGED
package/scss/colors/_theme.scss
CHANGED