richie-education 3.3.0 → 3.3.1-dev11

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.
@@ -44,8 +44,8 @@ jest.mock('utils/context', () => ({
44
44
  authentication: {
45
45
  backend: 'keycloak',
46
46
  endpoint: 'https://keycloak.test/auth',
47
- client_id: 'richie-client',
48
- realm: 'richie-realm',
47
+ keycloak_client_id: 'richie-client',
48
+ keycloak_realm: 'richie-realm',
49
49
  auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
50
50
  },
51
51
  }).one(),
@@ -55,8 +55,9 @@ describe('Keycloak API', () => {
55
55
  const authConfig = {
56
56
  backend: 'keycloak',
57
57
  endpoint: 'https://keycloak.test/auth',
58
- client_id: 'richie-client',
59
- realm: 'richie-realm',
58
+ keycloak_endpoint: 'https://keycloak.test/auth',
59
+ keycloak_client_id: 'richie-client',
60
+ keycloak_realm: 'richie-realm',
60
61
  auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
61
62
  registration_url:
62
63
  'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/registrations',
@@ -84,6 +85,22 @@ describe('Keycloak API', () => {
84
85
  });
85
86
 
86
87
  describe('user.me', () => {
88
+ it('returns null when init returns false (not authenticated)', async () => {
89
+ mockKeycloakInit.mockResolvedValueOnce(false);
90
+ const api = API(authConfig);
91
+ const response = await api.user.me();
92
+ expect(response).toBeNull();
93
+ expect(mockKeycloakUpdateToken).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('returns null when init rejects', async () => {
97
+ mockKeycloakInit.mockRejectedValueOnce(new Error('Init failed'));
98
+ const api = API(authConfig);
99
+ const response = await api.user.me();
100
+ expect(response).toBeNull();
101
+ expect(mockKeycloakUpdateToken).not.toHaveBeenCalled();
102
+ });
103
+
87
104
  it('returns null when updateToken fails', async () => {
88
105
  mockKeycloakUpdateToken.mockRejectedValueOnce(new Error('Token refresh failed'));
89
106
  const response = await keycloakApi.user.me();
@@ -9,10 +9,10 @@ import { RICHIE_USER_TOKEN } from 'settings';
9
9
  const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
10
10
  const keycloak = new Keycloak({
11
11
  url: APIConf.endpoint,
12
- realm: APIConf.realm!,
13
- clientId: APIConf.client_id!,
12
+ realm: APIConf.keycloak_realm!,
13
+ clientId: APIConf.keycloak_client_id!,
14
14
  });
15
- keycloak.init({
15
+ const initPromise = keycloak.init({
16
16
  checkLoginIframe: false,
17
17
  flow: 'standard',
18
18
  onLoad: 'check-sso',
@@ -39,6 +39,15 @@ const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
39
39
  user: {
40
40
  accessToken: () => sessionStorage.getItem(RICHIE_USER_TOKEN),
41
41
  me: async () => {
42
+ let isAuthenticated: boolean;
43
+ try {
44
+ isAuthenticated = await initPromise;
45
+ } catch (error) {
46
+ handle(error);
47
+ return null;
48
+ }
49
+ if (!isAuthenticated) return null;
50
+
42
51
  try {
43
52
  await keycloak.updateToken(30);
44
53
  } catch (error) {
@@ -15,6 +15,7 @@ import KeycloakApiInterface from './auth/keycloak';
15
15
  import OpenEdxDogwoodApiInterface from './lms/openedx-dogwood';
16
16
  import OpenEdxHawthornApiInterface from './lms/openedx-hawthorn';
17
17
  import OpenEdxFonzieApiInterface from './lms/openedx-fonzie';
18
+ import OpenEdxFonzieKeycloakApiInterface from './lms/openedx-fonzie-keycloak';
18
19
 
19
20
  const AuthenticationAPIHandler = (): Nullable<APIAuthentication> => {
20
21
  const AUTHENTICATION: AuthenticationBackend = context?.authentication;
@@ -31,6 +32,8 @@ const AuthenticationAPIHandler = (): Nullable<APIAuthentication> => {
31
32
  return OpenEdxHawthornApiInterface(AUTHENTICATION).user;
32
33
  case APIBackend.FONZIE:
33
34
  return OpenEdxFonzieApiInterface(AUTHENTICATION).user;
35
+ case APIBackend.FONZIE_KEYCLOAK:
36
+ return OpenEdxFonzieKeycloakApiInterface(AUTHENTICATION).user;
34
37
  default:
35
38
  handle(new Error(`No Authentication Backend found for ${AUTHENTICATION.backend}.`));
36
39
  return null;
@@ -0,0 +1,79 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import fetchMock from 'fetch-mock';
3
+ import { RICHIE_USER_TOKEN } from 'settings';
4
+ import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
5
+ import { location } from 'utils/indirection/window';
6
+ import FonzieKeycloakAPIInterface from './openedx-fonzie-keycloak';
7
+
8
+ jest.mock('utils/context', () => ({
9
+ __esModule: true,
10
+ default: null,
11
+ }));
12
+
13
+ jest.mock('utils/indirection/window', () => ({
14
+ location: {
15
+ href: 'http://localhost/',
16
+ assign: jest.fn(),
17
+ },
18
+ }));
19
+
20
+ describe('Fonzie Keycloak API', () => {
21
+ const configuration = {
22
+ backend: 'fonzie-keycloak',
23
+ course_regexp: '.*',
24
+ endpoint: 'https://demo.endpoint.api',
25
+ keycloak_endpoint: 'https://keycloak.test/auth',
26
+ keycloak_realm: 'richie-realm',
27
+ };
28
+
29
+ beforeEach(() => {
30
+ fetchMock.restore();
31
+ jest.clearAllMocks();
32
+ });
33
+
34
+ it('uses its own route to get user information', async () => {
35
+ const user = {
36
+ username: faker.internet.username(),
37
+ };
38
+
39
+ fetchMock.get('https://demo.endpoint.api/api/v1.0/user/me', user);
40
+
41
+ const api = FonzieKeycloakAPIInterface(configuration);
42
+ await expect(api.user.me()).resolves.toEqual(user);
43
+ });
44
+
45
+ it('redirects to keycloak-login endpoint on login', () => {
46
+ const api = FonzieKeycloakAPIInterface(configuration);
47
+ api.user.login!();
48
+
49
+ expect(location.assign).toHaveBeenCalledWith(
50
+ `https://demo.endpoint.api/keycloak-login?next=${encodeURIComponent('http://localhost/')}`,
51
+ );
52
+ });
53
+
54
+ it('uses keycloak account URL to get user profile', async () => {
55
+ const openEdxApiProfile = OpenEdxApiProfileFactory().one();
56
+ const { 'pref-lang': language, ...account } = openEdxApiProfile;
57
+
58
+ fetchMock.get('https://keycloak.test/auth/realms/richie-realm/account/', account);
59
+ fetchMock.get(
60
+ `https://demo.endpoint.api/api/user/v1/preferences/${openEdxApiProfile.username}`,
61
+ { 'pref-lang': language },
62
+ );
63
+
64
+ const api = FonzieKeycloakAPIInterface(configuration);
65
+ expect(await api.user.account!.get(openEdxApiProfile.username)).toEqual(openEdxApiProfile);
66
+ expect(fetchMock.calls()).toHaveLength(2);
67
+ });
68
+
69
+ it('is able to retrieve access token within the session storage', () => {
70
+ const accessToken = faker.string.uuid();
71
+ sessionStorage.setItem(RICHIE_USER_TOKEN, accessToken);
72
+
73
+ const api = FonzieKeycloakAPIInterface(configuration);
74
+ expect(api.user.accessToken).not.toBeUndefined();
75
+
76
+ const token = api.user.accessToken!();
77
+ expect(token).toEqual(accessToken);
78
+ });
79
+ });
@@ -0,0 +1,106 @@
1
+ import Cookies from 'js-cookie';
2
+ import { AuthenticationBackend } from 'types/commonDataProps';
3
+ import { APILms } from 'types/api';
4
+ import { RICHIE_USER_TOKEN, EDX_CSRF_TOKEN_COOKIE_NAME } from 'settings';
5
+ import { isHttpError } from 'utils/errors/HttpError';
6
+ import { handle } from 'utils/errors/handle';
7
+ import { OpenEdxApiProfile } from 'types/openEdx';
8
+ import { checkStatus } from 'api/utils';
9
+ import { OpenEdxFullNameFormValues } from 'components/OpenEdxFullNameForm';
10
+ import { location } from 'utils/indirection/window';
11
+ import OpenEdxHawthornApiInterface from './openedx-hawthorn';
12
+
13
+ /**
14
+ *
15
+ * OpenEdX completed by Fonzie API Implementation
16
+ *
17
+ * This implementation inherits from Hawthorn implementation.
18
+ * The `user.me` method has to be overriden to retrieve user information from
19
+ * fonzie API to retrieve a JWT Token
20
+ *
21
+ * A method `accessToken` has been added to retrieve the access_token
22
+ * stored in the persisted client by react query within SessionStorage.
23
+ *
24
+ * Related resources:
25
+ * https://github.com/openfun/fonzie/pull/24
26
+ *
27
+ */
28
+
29
+ const API = (APIConf: AuthenticationBackend): APILms => {
30
+ const APIOptions = {
31
+ routes: {
32
+ user: {
33
+ login: `${APIConf.endpoint}/keycloak-login`,
34
+ me: `${APIConf.endpoint}/api/v1.0/user/me`,
35
+ account: `${APIConf.keycloak_endpoint}/realms/${APIConf.keycloak_realm}/account/`,
36
+ preferences: `${APIConf.endpoint}/api/user/v1/preferences/:username`,
37
+ },
38
+ },
39
+ };
40
+
41
+ const ApiInterface = OpenEdxHawthornApiInterface(APIConf, APIOptions);
42
+ return {
43
+ ...ApiInterface,
44
+ user: {
45
+ ...ApiInterface.user,
46
+ login: () => {
47
+ const next = encodeURIComponent(location.href);
48
+ location.assign(`${APIOptions.routes.user.login}?next=${next}`);
49
+ },
50
+ accessToken: () => {
51
+ return sessionStorage.getItem(RICHIE_USER_TOKEN);
52
+ },
53
+ account: {
54
+ get: async (username: string) => {
55
+ const options: RequestInit = {
56
+ credentials: 'include',
57
+ };
58
+
59
+ try {
60
+ const account = await fetch(
61
+ APIOptions.routes.user.account.replace(':username', username),
62
+ options,
63
+ ).then(checkStatus);
64
+ const preferences = await fetch(
65
+ APIOptions.routes.user.preferences.replace(':username', username),
66
+ options,
67
+ ).then(checkStatus);
68
+
69
+ return {
70
+ ...account,
71
+ ...preferences,
72
+ } as OpenEdxApiProfile;
73
+ } catch (e) {
74
+ if (isHttpError(e)) {
75
+ handle(new Error(`[GET - Account] > ${e.code} - ${e.message}`));
76
+ }
77
+
78
+ throw e;
79
+ }
80
+ },
81
+ update: async (username: string, data: OpenEdxFullNameFormValues) => {
82
+ const csrfToken = Cookies.get(EDX_CSRF_TOKEN_COOKIE_NAME) || '';
83
+ try {
84
+ return await fetch(APIOptions.routes.user.account.replace(':username', username), {
85
+ method: 'PATCH',
86
+ credentials: 'include',
87
+ headers: {
88
+ 'Content-Type': 'application/merge-patch+json',
89
+ 'X-CSRFTOKEN': csrfToken,
90
+ },
91
+ body: JSON.stringify(data),
92
+ }).then(checkStatus);
93
+ } catch (e) {
94
+ if (isHttpError(e)) {
95
+ handle(new Error(`[POST - Account] > ${e.code} - ${e.message}`));
96
+ }
97
+
98
+ throw e;
99
+ }
100
+ },
101
+ },
102
+ },
103
+ };
104
+ };
105
+
106
+ export default API;
@@ -146,9 +146,8 @@ export const SaleTunnelInformationSingular = () => {
146
146
  const discount = query.data?.discount ?? props.paymentPlan?.discount;
147
147
  const fromBatchOrder = query.data?.from_batch_order ?? props.paymentPlan?.from_batch_order;
148
148
 
149
- const showPaymentSchedule =
149
+ const isCredentialWithPrice =
150
150
  product.type === ProductType.CREDENTIAL &&
151
- schedule &&
152
151
  (discountedPrice != null ? discountedPrice > 0 : price != null && price > 0);
153
152
 
154
153
  useEffect(() => {
@@ -168,7 +167,9 @@ export const SaleTunnelInformationSingular = () => {
168
167
  setNeedsPayment(!fromBatchOrder);
169
168
  }, [fromBatchOrder, setNeedsPayment]);
170
169
 
171
- const isKeycloakBackend = context?.authentication.backend === APIBackend.KEYCLOAK;
170
+ const isKeycloakBackend = [APIBackend.KEYCLOAK, APIBackend.FONZIE_KEYCLOAK].includes(
171
+ context?.authentication.backend as APIBackend,
172
+ );
172
173
 
173
174
  return (
174
175
  <>
@@ -204,18 +205,19 @@ export const SaleTunnelInformationSingular = () => {
204
205
  </div>
205
206
  )}
206
207
  <div>
207
- {showPaymentSchedule ? (
208
- <PaymentScheduleBlock schedule={schedule!} />
209
- ) : (
210
- <div>
211
- <h4 className="block-title">
212
- <FormattedMessage {...messages.paymentSchedule} />
213
- </h4>
214
- <Alert type={VariantType.NEUTRAL}>
215
- <FormattedMessage {...messages.noPaymentSchedule} />
216
- </Alert>
217
- </div>
218
- )}
208
+ {isCredentialWithPrice &&
209
+ (schedule ? (
210
+ <PaymentScheduleBlock schedule={schedule!} />
211
+ ) : (
212
+ <div>
213
+ <h4 className="block-title">
214
+ <FormattedMessage {...messages.paymentSchedule} />
215
+ </h4>
216
+ <Alert type={VariantType.NEUTRAL}>
217
+ <FormattedMessage {...messages.noPaymentSchedule} />
218
+ </Alert>
219
+ </div>
220
+ ))}
219
221
  <Voucher
220
222
  discount={discount}
221
223
  voucherError={voucherError}
@@ -374,8 +374,16 @@ describe('SaleTunnel', () => {
374
374
  );
375
375
  await user.type(screen.getByLabelText('Voucher code'), 'DISCOUNT100');
376
376
  await user.click(screen.getByRole('button', { name: 'Validate' }));
377
- await screen.findByRole('heading', { level: 4, name: 'Payment schedule' });
378
- screen.getByText('No payment required. This order is fully covered.');
377
+ await screen.findByRole('heading', { level: 3, name: 'Information' });
378
+ await screen.findByText(
379
+ 'No billing information required. This order is covered by your organization.',
380
+ );
381
+ expect(
382
+ screen.queryByRole('heading', { level: 4, name: 'Payment schedule' }),
383
+ ).not.toBeInTheDocument();
384
+ expect(
385
+ screen.queryByText('No payment required. This order is fully covered.'),
386
+ ).not.toBeInTheDocument();
379
387
  await screen.findByTestId('sale-tunnel__total__amount');
380
388
  const $totalAmountVoucher = screen.getByTestId('sale-tunnel__total__amount');
381
389
  expect($totalAmountVoucher).toHaveTextContent(
@@ -484,8 +484,12 @@ describe.each([
484
484
  });
485
485
  });
486
486
  } else {
487
- await screen.findByRole('heading', { level: 4, name: 'Payment schedule' });
488
- screen.getByText('No payment required. This order is fully covered.');
487
+ expect(
488
+ screen.queryByRole('heading', { level: 4, name: 'Payment schedule' }),
489
+ ).not.toBeInTheDocument();
490
+ expect(
491
+ screen.queryByText('No payment required. This order is fully covered.'),
492
+ ).not.toBeInTheDocument();
489
493
  expect(screen.queryByRole('table')).toBeNull();
490
494
  }
491
495
 
@@ -624,8 +628,12 @@ describe.each([
624
628
  });
625
629
  });
626
630
  } else {
627
- await screen.findByRole('heading', { level: 4, name: 'Payment schedule' });
628
- screen.getByText('No payment required. This order is fully covered.');
631
+ expect(
632
+ screen.queryByRole('heading', { level: 4, name: 'Payment schedule' }),
633
+ ).not.toBeInTheDocument();
634
+ expect(
635
+ screen.queryByText('No payment required. This order is fully covered.'),
636
+ ).not.toBeInTheDocument();
629
637
  expect(screen.queryByRole('table')).toBeNull();
630
638
  }
631
639
 
@@ -838,10 +846,12 @@ describe.each([
838
846
  'No billing information required. This order is covered by your organization.',
839
847
  ),
840
848
  ).toBeInTheDocument();
841
- expect(screen.getByRole('heading', { level: 4, name: 'Payment schedule' })).toBeInTheDocument();
842
849
  expect(
843
- screen.getByText('No payment required. This order is fully covered.'),
844
- ).toBeInTheDocument();
850
+ screen.queryByRole('heading', { level: 4, name: 'Payment schedule' }),
851
+ ).not.toBeInTheDocument();
852
+ expect(
853
+ screen.queryByText('No payment required. This order is fully covered.'),
854
+ ).not.toBeInTheDocument();
845
855
  });
846
856
 
847
857
  it('should hide voucher code input when one is already used', async () => {
@@ -1022,4 +1032,52 @@ describe('SaleTunnel with Keycloak backend', () => {
1022
1032
  // No OpenEdx profile API calls should have been made
1023
1033
  expect(fetchMock.calls().filter(([url]) => url.includes('/api/user/v1/'))).toHaveLength(0);
1024
1034
  });
1035
+
1036
+ it('should render keycloak account info when using fonzie-keycloak backend', async () => {
1037
+ // Switch to fonzie-keycloak backend
1038
+ const ctx = require('utils/context').default;
1039
+ ctx.authentication.backend = 'fonzie-keycloak';
1040
+
1041
+ const product = CredentialProductFactory().one();
1042
+ const paymentPlan = PaymentPlanFactory().one();
1043
+
1044
+ fetchMock
1045
+ .get(
1046
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify({
1047
+ course_code: course.code,
1048
+ product_id: product.id,
1049
+ state: NOT_CANCELED_ORDER_STATES,
1050
+ })}`,
1051
+ [],
1052
+ )
1053
+ .get(
1054
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
1055
+ paymentPlan,
1056
+ );
1057
+
1058
+ render(<Wrapper product={product} isWithdrawable={true} paymentPlan={paymentPlan} />, {
1059
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
1060
+ });
1061
+
1062
+ // Should display the "Account name" heading (keycloak flow)
1063
+ await screen.findByRole('heading', { level: 4, name: 'Account name' });
1064
+
1065
+ // Should display the username from the session
1066
+ screen.getByText(richieUser.username);
1067
+
1068
+ // Should display the email from the session
1069
+ screen.getByText(richieUser.email!);
1070
+
1071
+ // Should display the keycloak account update link
1072
+ const updateLink = screen.getByRole('link', {
1073
+ name: 'please update your account',
1074
+ });
1075
+ expect(updateLink).toHaveAttribute('href', mockAccountUpdateUrl);
1076
+
1077
+ // Should NOT render the OpenEdx full name form
1078
+ expect(screen.queryByLabelText('First name and last name')).not.toBeInTheDocument();
1079
+
1080
+ // No OpenEdx profile API calls should have been made
1081
+ expect(fetchMock.calls().filter(([url]) => url.includes('/api/user/v1/'))).toHaveLength(0);
1082
+ });
1025
1083
  });
@@ -0,0 +1,77 @@
1
+ import { screen } from '@testing-library/dom';
2
+ import {
3
+ UserFactory,
4
+ RichieContextFactory as mockRichieContextFactory,
5
+ } from 'utils/test/factories/richie';
6
+ import { render } from 'utils/test/render';
7
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
8
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
9
+ import { User } from 'types/User';
10
+ import { AuthenticationApi } from 'api/authentication';
11
+ import { APIAuthentication } from 'types/api';
12
+ import DashboardKeycloakProfile, { DEFAULT_DISPLAYED_FORM_VALUE } from '.';
13
+
14
+ jest.mock('utils/context', () => ({
15
+ __esModule: true,
16
+ default: mockRichieContextFactory({
17
+ authentication: {
18
+ endpoint: 'https://endpoint.test',
19
+ backend: 'fonzie',
20
+ },
21
+ joanie_backend: {
22
+ endpoint: 'https://joanie.endpoint',
23
+ },
24
+ }).one(),
25
+ }));
26
+
27
+ describe('pages.DashboardKeycloakProfile', () => {
28
+ let richieUser: User;
29
+ let originalAccount: APIAuthentication['account'];
30
+ const mockAccountUpdateUrl = 'https://keycloak.test/auth/realms/richie/account';
31
+ setupJoanieSession();
32
+
33
+ beforeEach(() => {
34
+ richieUser = UserFactory().one();
35
+ originalAccount = AuthenticationApi!.account;
36
+ AuthenticationApi!.account = {
37
+ get: () => ({
38
+ username: richieUser.username,
39
+ email: richieUser.email,
40
+ firstName: null,
41
+ lastName: null,
42
+ }),
43
+ updateUrl: () => mockAccountUpdateUrl,
44
+ };
45
+ });
46
+
47
+ afterEach(() => {
48
+ AuthenticationApi!.account = originalAccount;
49
+ });
50
+
51
+ it('should render profile information', async () => {
52
+ render(<DashboardKeycloakProfile />, {
53
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
54
+ });
55
+
56
+ expect(screen.getByText('Profile')).toBeInTheDocument();
57
+ expect(screen.getByText('Account information')).toBeInTheDocument();
58
+
59
+ expect(await screen.findByDisplayValue(richieUser.username)).toBeInTheDocument();
60
+ expect(screen.getByDisplayValue(richieUser.email!)).toBeInTheDocument();
61
+
62
+ const editLink = screen.getByRole('link', { name: 'Edit your profile' });
63
+ expect(editLink).toBeInTheDocument();
64
+ expect(editLink).toHaveAttribute('href', mockAccountUpdateUrl);
65
+ });
66
+
67
+ it('should render default values when user fields are empty', async () => {
68
+ const userWithoutEmail = UserFactory({ email: undefined }).one();
69
+
70
+ render(<DashboardKeycloakProfile />, {
71
+ queryOptions: { client: createTestQueryClient({ user: userWithoutEmail }) },
72
+ });
73
+
74
+ expect(await screen.findByDisplayValue(userWithoutEmail.username)).toBeInTheDocument();
75
+ expect(screen.getByLabelText('Account email')).toHaveValue(DEFAULT_DISPLAYED_FORM_VALUE);
76
+ });
77
+ });
@@ -0,0 +1,92 @@
1
+ import { Button, Input } from '@openfun/cunningham-react';
2
+ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
3
+ import { useSession } from 'contexts/SessionContext';
4
+ import { DashboardBox } from 'widgets/Dashboard/components/DashboardBox';
5
+ import { DashboardCard } from 'widgets/Dashboard/components/DashboardCard';
6
+ import Form from 'components/Form';
7
+ import { AuthenticationApi } from 'api/authentication';
8
+ import { KeycloakAccountApi } from 'types/api';
9
+
10
+ const messages = defineMessages({
11
+ sectionHeader: {
12
+ id: 'components.DashboardKeycloakProfile.header',
13
+ description: 'Title of the dashboard keycloak profile block',
14
+ defaultMessage: 'Profile',
15
+ },
16
+ accountInformationHeader: {
17
+ id: 'components.DashboardKeycloakProfile.accountInformationHeader',
18
+ description: 'Title of the keycloak profile form "account information" block',
19
+ defaultMessage: 'Account information',
20
+ },
21
+ editButtonLabel: {
22
+ id: 'components.DashboardKeycloakProfile.editButtonLabel',
23
+ description: 'Label of the edit button link of the keycloak profile',
24
+ defaultMessage: 'Edit your profile',
25
+ },
26
+ usernameInputLabel: {
27
+ id: 'components.DashboardKeycloakProfile.usernameInputLabel',
28
+ description: 'Label of the keycloak profile "username" input',
29
+ defaultMessage: 'Account name',
30
+ },
31
+ usernameInputDescription: {
32
+ id: 'components.DashboardKeycloakProfile.usernameInputDescription',
33
+ description: 'Description of the keycloak profile "username" input',
34
+ defaultMessage: 'This name will be used in legal documents.',
35
+ },
36
+ emailInputLabel: {
37
+ id: 'components.DashboardKeycloakProfile.emailInputLabel',
38
+ description: 'Label of the keycloak profile "email" input',
39
+ defaultMessage: 'Account email',
40
+ },
41
+ emailInputDescription: {
42
+ id: 'components.DashboardKeycloakProfile.emailInputDescription',
43
+ description: 'Description of the keycloak profile "email" input',
44
+ defaultMessage: 'This email will be used to send you confirmation mails.',
45
+ },
46
+ });
47
+
48
+ export const DEFAULT_DISPLAYED_FORM_VALUE = ' - ';
49
+
50
+ const DashboardKeycloakProfile = () => {
51
+ const intl = useIntl();
52
+ const { user } = useSession();
53
+ const accountApi = AuthenticationApi!.account as KeycloakAccountApi;
54
+
55
+ return (
56
+ <DashboardCard header={<FormattedMessage {...messages.sectionHeader} />}>
57
+ <DashboardBox.List>
58
+ <DashboardBox header={<FormattedMessage {...messages.accountInformationHeader} />}>
59
+ <Form.Row>
60
+ <Input
61
+ className="form-field"
62
+ fullWidth
63
+ name="username"
64
+ label={intl.formatMessage(messages.usernameInputLabel)}
65
+ disabled={true}
66
+ value={user?.username || DEFAULT_DISPLAYED_FORM_VALUE}
67
+ text={intl.formatMessage(messages.usernameInputDescription)}
68
+ />
69
+ </Form.Row>
70
+ <Form.Row>
71
+ <Input
72
+ className="form-field"
73
+ fullWidth
74
+ name="email"
75
+ label={intl.formatMessage(messages.emailInputLabel)}
76
+ disabled={true}
77
+ value={user?.email || DEFAULT_DISPLAYED_FORM_VALUE}
78
+ text={intl.formatMessage(messages.emailInputDescription)}
79
+ />
80
+ </Form.Row>
81
+ </DashboardBox>
82
+ </DashboardBox.List>
83
+
84
+ <Form.Footer>
85
+ <Button fullWidth href={accountApi.updateUrl()}>
86
+ <FormattedMessage {...messages.editButtonLabel} />
87
+ </Button>
88
+ </Form.Footer>
89
+ </DashboardCard>
90
+ );
91
+ };
92
+ export default DashboardKeycloakProfile;
@@ -0,0 +1,141 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import fetchMock from 'fetch-mock';
3
+ import {
4
+ UserFactory,
5
+ RichieContextFactory as mockRichieContextFactory,
6
+ } from 'utils/test/factories/richie';
7
+ import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
8
+ import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
9
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
10
+ import { User } from 'types/User';
11
+ import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
12
+ import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
13
+ import context from 'utils/context';
14
+ import { AuthenticationApi } from 'api/authentication';
15
+
16
+ jest.mock('utils/context', () => ({
17
+ __esModule: true,
18
+ default: mockRichieContextFactory({
19
+ authentication: { backend: 'fonzie', endpoint: 'https://endpoint.test' },
20
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
21
+ }).one(),
22
+ }));
23
+
24
+ jest.mock('utils/indirection/window', () => ({
25
+ confirm: jest.fn(() => true),
26
+ }));
27
+
28
+ describe('<DashboardPreferences />', () => {
29
+ let richieUser: User;
30
+ const mockAccountUpdateUrl = 'https://keycloak.test/auth/realms/richie/account';
31
+
32
+ beforeEach(() => {
33
+ richieUser = UserFactory().one();
34
+ const openEdxProfile = OpenEdxApiProfileFactory({
35
+ username: richieUser.username,
36
+ email: richieUser.email,
37
+ name: richieUser.full_name,
38
+ }).one();
39
+ const { 'pref-lang': prefLang, ...openEdxAccount } = openEdxProfile;
40
+
41
+ fetchMock.get('https://endpoint.test/api/v1.0/user/me', richieUser);
42
+ fetchMock.get(
43
+ `https://endpoint.test/api/user/v1/accounts/${richieUser.username}`,
44
+ openEdxAccount,
45
+ );
46
+ fetchMock.get(`https://endpoint.test/api/user/v1/preferences/${richieUser.username}`, {
47
+ 'pref-lang': prefLang,
48
+ });
49
+
50
+ fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', []);
51
+ fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', []);
52
+ fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', []);
53
+ });
54
+
55
+ afterEach(() => {
56
+ jest.clearAllMocks();
57
+ fetchMock.restore();
58
+ });
59
+
60
+ it('should render the OpenEdx profile section when using fonzie backend', async () => {
61
+ const client = createTestQueryClient({ user: richieUser });
62
+ await act(async () => {
63
+ render(
64
+ <BaseJoanieAppWrapper queryOptions={{ client }}>
65
+ <DashboardTest initialRoute={LearnerDashboardPaths.PREFERENCES} />
66
+ </BaseJoanieAppWrapper>,
67
+ );
68
+ });
69
+
70
+ // The OpenEdx profile section should be visible
71
+ await screen.findByText('Profile');
72
+ });
73
+
74
+ it('should render the keycloak profile section when using keycloak backend', async () => {
75
+ const originalBackend = context.authentication.backend;
76
+ context.authentication.backend = 'keycloak';
77
+ const originalAccount = AuthenticationApi!.account;
78
+ AuthenticationApi!.account = {
79
+ get: () => ({
80
+ username: richieUser.username,
81
+ email: richieUser.email,
82
+ firstName: null,
83
+ lastName: null,
84
+ }),
85
+ updateUrl: () => mockAccountUpdateUrl,
86
+ };
87
+
88
+ const client = createTestQueryClient({ user: richieUser });
89
+ await act(async () => {
90
+ render(
91
+ <BaseJoanieAppWrapper queryOptions={{ client }}>
92
+ <DashboardTest initialRoute={LearnerDashboardPaths.PREFERENCES} />
93
+ </BaseJoanieAppWrapper>,
94
+ );
95
+ });
96
+
97
+ // The keycloak profile section should be visible
98
+ await screen.findByText('Account name');
99
+ await screen.findByText('Account email');
100
+ await screen.findByText('Edit your profile');
101
+ // The OpenEdx-specific section should NOT be visible
102
+ expect(screen.queryByText('Basic account information')).not.toBeInTheDocument();
103
+
104
+ context.authentication.backend = originalBackend;
105
+ AuthenticationApi!.account = originalAccount;
106
+ });
107
+
108
+ it('should render the keycloak profile section when using fonzie-keycloak backend', async () => {
109
+ const originalBackend = context.authentication.backend;
110
+ context.authentication.backend = 'fonzie-keycloak';
111
+ const originalAccount = AuthenticationApi!.account;
112
+ AuthenticationApi!.account = {
113
+ get: () => ({
114
+ username: richieUser.username,
115
+ email: richieUser.email,
116
+ firstName: null,
117
+ lastName: null,
118
+ }),
119
+ updateUrl: () => mockAccountUpdateUrl,
120
+ };
121
+
122
+ const client = createTestQueryClient({ user: richieUser });
123
+ await act(async () => {
124
+ render(
125
+ <BaseJoanieAppWrapper queryOptions={{ client }}>
126
+ <DashboardTest initialRoute={LearnerDashboardPaths.PREFERENCES} />
127
+ </BaseJoanieAppWrapper>,
128
+ );
129
+ });
130
+
131
+ // The keycloak profile section should be visible
132
+ await screen.findByText('Account name');
133
+ await screen.findByText('Account email');
134
+ await screen.findByText('Edit your profile');
135
+ // The OpenEdx-specific section should NOT be visible
136
+ expect(screen.queryByText('Basic account information')).not.toBeInTheDocument();
137
+
138
+ context.authentication.backend = originalBackend;
139
+ AuthenticationApi!.account = originalAccount;
140
+ });
141
+ });
@@ -2,17 +2,23 @@ import { DashboardCreditCardsManagement } from 'pages/DashboardCreditCardsManage
2
2
  import { DashboardAddressesManagement } from 'pages/DashboardAddressesManagement';
3
3
  import { useDashboardNavigate } from 'widgets/Dashboard/hooks/useDashboardRouter';
4
4
  import DashboardOpenEdxProfile from 'pages/DashboardOpenEdxProfile';
5
+ import DashboardKeycloakProfile from 'pages/DashboardKeycloakProfile';
5
6
 
6
7
  import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
8
+ import { APIBackend } from 'types/api';
9
+ import context from 'utils/context';
7
10
 
8
11
  /**
9
12
  * This component relies on react-router.
10
13
  */
11
14
  export const DashboardPreferences = () => {
12
15
  const navigate = useDashboardNavigate();
16
+ const isKeycloakBackend = [APIBackend.KEYCLOAK, APIBackend.FONZIE_KEYCLOAK].includes(
17
+ context?.authentication.backend as APIBackend,
18
+ );
13
19
  return (
14
20
  <div className="dashboard-preferences">
15
- <DashboardOpenEdxProfile />
21
+ {isKeycloakBackend ? <DashboardKeycloakProfile /> : <DashboardOpenEdxProfile />}
16
22
  <DashboardAddressesManagement
17
23
  onClickCreate={() => navigate(LearnerDashboardPaths.PREFERENCES_ADDRESS_CREATION)}
18
24
  onClickEdit={(address) =>
package/js/types/api.ts CHANGED
@@ -68,6 +68,7 @@ export interface APIOptions {
68
68
  export enum APIBackend {
69
69
  DUMMY = 'dummy',
70
70
  FONZIE = 'fonzie',
71
+ FONZIE_KEYCLOAK = 'fonzie-keycloak',
71
72
  JOANIE = 'joanie',
72
73
  KEYCLOAK = 'keycloak',
73
74
  OPENEDX_DOGWOOD = 'openedx-dogwood',
@@ -14,8 +14,9 @@ export interface LMSBackend {
14
14
  export interface AuthenticationBackend {
15
15
  backend: string;
16
16
  endpoint: string;
17
- client_id?: string;
18
- realm?: string;
17
+ keycloak_client_id?: string;
18
+ keycloak_endpoint?: string;
19
+ keycloak_realm?: string;
19
20
  token?: string;
20
21
  auth_url?: string;
21
22
  registration_url?: string;
@@ -7,8 +7,6 @@ import {
7
7
  DashboardSidebarProps,
8
8
  } from 'widgets/Dashboard/components/DashboardSidebar';
9
9
  import { useSession } from 'contexts/SessionContext';
10
- import { APIBackend } from 'types/api';
11
- import context from 'utils/context';
12
10
  import { UserHelper } from 'utils/UserHelper';
13
11
 
14
12
  import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
@@ -32,18 +30,15 @@ export const LearnerDashboardSidebar = (props: Partial<DashboardSidebarProps>) =
32
30
 
33
31
  const getRouteLabel = getDashboardRouteLabel(intl);
34
32
 
35
- const dashboardPaths = [
36
- LearnerDashboardPaths.COURSES,
37
- LearnerDashboardPaths.CERTIFICATES,
38
- LearnerDashboardPaths.CONTRACTS,
39
- LearnerDashboardPaths.BATCH_ORDERS,
40
- ];
41
- if (context?.authentication.backend !== APIBackend.KEYCLOAK) {
42
- dashboardPaths.push(LearnerDashboardPaths.PREFERENCES);
43
- }
44
33
  const links = useMemo(
45
34
  () =>
46
- dashboardPaths.map((path) => ({
35
+ [
36
+ LearnerDashboardPaths.COURSES,
37
+ LearnerDashboardPaths.CERTIFICATES,
38
+ LearnerDashboardPaths.CONTRACTS,
39
+ LearnerDashboardPaths.PREFERENCES,
40
+ LearnerDashboardPaths.BATCH_ORDERS,
41
+ ].map((path) => ({
47
42
  to: generatePath(path),
48
43
  label: getRouteLabel(path),
49
44
  activePaths:
@@ -212,7 +212,8 @@ describe('<Dashboard />', () => {
212
212
  expect(screen.getByRole('heading', { name: /Page not found/ }));
213
213
  });
214
214
 
215
- it('should not show preferences link when using keycloak backend', async () => {
215
+ it('should show preferences link when using keycloak backend', async () => {
216
+ // The guard moved from the sidebar to DashboardPreferences itself.
216
217
  // Temporarily change the authentication backend to keycloak
217
218
  const originalBackend = context.authentication.backend;
218
219
  context.authentication.backend = 'keycloak';
@@ -224,8 +225,8 @@ describe('<Dashboard />', () => {
224
225
 
225
226
  const sidebar = screen.getByTestId('dashboard__sidebar');
226
227
 
227
- // Verify that "My preferences" link is NOT present in the sidebar
228
- expect(queryByText(sidebar, 'My preferences')).toBeNull();
228
+ // "My preferences" link is always present in the sidebar regardless of backend
229
+ expect(queryByText(sidebar, 'My preferences')).not.toBeNull();
229
230
 
230
231
  // Restore original backend
231
232
  context.authentication.backend = originalBackend;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.3.0",
3
+ "version": "3.3.1-dev11",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {