richie-education 3.2.2-dev45 → 3.2.2-dev52

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