richie-education 3.3.1-dev1 → 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;
@@ -167,7 +167,9 @@ export const SaleTunnelInformationSingular = () => {
167
167
  setNeedsPayment(!fromBatchOrder);
168
168
  }, [fromBatchOrder, setNeedsPayment]);
169
169
 
170
- const isKeycloakBackend = context?.authentication.backend === APIBackend.KEYCLOAK;
170
+ const isKeycloakBackend = [APIBackend.KEYCLOAK, APIBackend.FONZIE_KEYCLOAK].includes(
171
+ context?.authentication.backend as APIBackend,
172
+ );
171
173
 
172
174
  return (
173
175
  <>
@@ -1032,4 +1032,52 @@ describe('SaleTunnel with Keycloak backend', () => {
1032
1032
  // No OpenEdx profile API calls should have been made
1033
1033
  expect(fetchMock.calls().filter(([url]) => url.includes('/api/user/v1/'))).toHaveLength(0);
1034
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
+ });
1035
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.1-dev1",
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": {