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.
- package/js/api/auth/keycloak.spec.ts +21 -4
- package/js/api/auth/keycloak.ts +12 -3
- package/js/api/authentication.ts +3 -0
- package/js/api/lms/openedx-fonzie-keycloak.spec.ts +79 -0
- package/js/api/lms/openedx-fonzie-keycloak.ts +106 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular.tsx +3 -1
- package/js/components/SaleTunnel/index.spec.tsx +48 -0
- package/js/pages/DashboardKeycloakProfile/index.spec.tsx +77 -0
- package/js/pages/DashboardKeycloakProfile/index.tsx +92 -0
- package/js/pages/DashboardPreferences/index.spec.tsx +141 -0
- package/js/pages/DashboardPreferences/index.tsx +7 -1
- package/js/types/api.ts +1 -0
- package/js/types/commonDataProps.ts +3 -2
- package/js/widgets/Dashboard/components/LearnerDashboardSidebar/index.tsx +7 -12
- package/js/widgets/Dashboard/index.spec.tsx +4 -3
- package/package.json +1 -1
|
@@ -44,8 +44,8 @@ jest.mock('utils/context', () => ({
|
|
|
44
44
|
authentication: {
|
|
45
45
|
backend: 'keycloak',
|
|
46
46
|
endpoint: 'https://keycloak.test/auth',
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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();
|
package/js/api/auth/keycloak.ts
CHANGED
|
@@ -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.
|
|
13
|
-
clientId: APIConf.
|
|
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) {
|
package/js/api/authentication.ts
CHANGED
|
@@ -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 =
|
|
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
|
@@ -14,8 +14,9 @@ export interface LMSBackend {
|
|
|
14
14
|
export interface AuthenticationBackend {
|
|
15
15
|
backend: string;
|
|
16
16
|
endpoint: string;
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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;
|