Compare commits

..

56 Commits

Author SHA1 Message Date
Adolfo R. Brandes
c0cf4623a4 refactor: decouple PasswordField from RegisterContext via props
PasswordField is a shared component used across login, registration,
and reset-password flows, but it was reaching directly into
RegisterContext for validation state and callbacks.  Replace context
coupling with explicit props (validateField, clearRegistrationBackendError,
validationApiRateLimited) passed by RegistrationPage, and remove the
now-unused useRegisterContextOptional hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
12670240b3 style: add explanatory comments for late imports after jest.mock()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
444e825fde test: restore missing test coverage for items 6, 8, 9
Adds tests that were removed or simplified during the Redux-to-React-Query
migration, as identified in PR #1641 review.

Logistration: CSRF token on mount, institution login page events for both
login and register pages, institution login track+page event assertions.

LoginPage: reset password banner dismissal on form submit, SSO redirect
to finishAuthUrl, and redirectUrl precedence over finishAuthUrl.

ProgressiveProfiling: sendPageEvent('login_and_registration', 'welcome')
assertion on component mount.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
c2a3c70c9d fix: address review nits for test mock cleanup
Remove unused useThirdPartyAuthHook mock body, inline dead
mockSetShowError variable, and extract shared TPA context mock
constant in Logistration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
56d8a10694 fix: add missing deps to registrationResult useEffect
Add USER_RETENTION_COOKIE_NAME and SESSION_COOKIE_DOMAIN to the
dependency array instead of suppressing react-hooks/exhaustive-deps.
Both are config constants from useAppConfig() that never change at
runtime, so behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
86b4ea79de perf: add enabled flag to TPA query hook to skip unnecessary fetches
In ProgressiveProfiling, the TPA query fired even when
registrationEmbedded was false and the result was ignored. Added an
enabled option to useThirdPartyAuthHook and set it to
registrationEmbedded in ProgressiveProfiling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
8dcf55254e fix: call setThirdPartyAuthContextBegin only on mount in LoginPage
It was being called on every data sync effect run, causing a flash of
PENDING state even when TPA data was already available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
e52d3d89fd fix: include payload in TPA query key to prevent stale cache
The query key only included pageId, so if the payload (tpa_hint, query
params, etc.) changed while pageId stayed the same, React Query would
serve stale cached data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
bb3ab6cba4 perf: add staleTime to third-party auth context query
The TPA context is effectively static per session. Adding a 5-minute
staleTime prevents unnecessary background refetches when navigating
between login/register tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
fc40952da3 fix: correct LoginContext setter types and remove dead PASSWORD_RESET_ERROR code
LoginContext setFormFields/setErrors are useState setters that accept
updater functions, so their types should be Dispatch<SetStateAction<...>>
rather than plain function signatures.

PASSWORD_RESET_ERROR was checked in ResetPasswordPage but no code path
ever set status to that value, making the conditions dead code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
1733f6ec01 fix: remove duplicate RedirectLogistration in ProgressiveProfiling
When shouldRedirect was true and welcomePageContext.nextUrl was truthy,
both RedirectLogistration components rendered simultaneously, causing a
double redirect attempt. The first block was a subset of the second, so
removing it is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
5d8743fb29 fix: use functional state updater in validateInput to prevent stale closure
validateInput was directly mutating the formErrors state object and
then spreading it. When handleSubmit called validateInput twice in
succession, the second call operated on stale closure values because
React batches state updates. Also fixed handleOnFocus for the same
issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
70ffc552b5 fix: move navigate() calls from render to useEffect in ResetPasswordPage
Calling navigate() during the render phase triggers state updates in
the router, causing React warnings and potential infinite render loops
in strict mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
3dbd6a76df fix: pass correct arguments to setEmailSuggestionContext in handleSuggestionClick
The function expects two string arguments (suggestion, type) but was
being called with a single object, corrupting the email suggestion state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
cb3ad5c53a feat: migrate from Redux to React Query and React Context
Replace Redux + Redux-Saga with React Query (useMutation/useQuery) for
server state and React Context for UI/form state across all modules:
login, registration, forgot-password, reset-password, progressive-
profiling, and common-components.

Port of master commits 0d709d15 and 93bd0f24, adapted for
@openedx/frontend-base:
- getSiteConfig() instead of getConfig()
- useAppConfig() for per-app configuration
- @tanstack/react-query as peerDependency (shell provides QueryClient)
- CurrentAppProvider instead of AppProvider

Also fixes EnvironmentTypes circular dependency in site.config.test.tsx
by using string literal instead of enum import.

Co-Authored-By: Jesus Balderrama <jesus.balderrama.wgu@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Max Sokolski
65462e7d80 fix: adding value length check for full name field
Co-authored-by: Artur Filippovskii <118079918+filippovskii09@users.noreply.github.com>
Co-Authored-By: Adolfo R. Brandes <adolfo@axim.org>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
bydawen
7bfb5d16d0 fix: add missing space symbol to the 'additional.help.text' field
Co-Authored-By: Adolfo R. Brandes <adolfo@axim.org>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
kshitij.sobti
c31c397c61 feat: add Slot for login page
This change adds a Slot for the login page allowing it to be customised.

Since this touched the Login Page, LoginPage and Logistration have also
been refactored to move away from redux connect.

Adapted for frontend-base: uses Slot from @openedx/frontend-base instead
of PluginSlot from @openedx/frontend-plugin-framework, slot files live
under src/slots/, and the slot ID follows the frontend-base naming
convention (org.openedx.frontend.slot.authn.loginComponent.v1).

Co-Authored-By: Adolfo R. Brandes <adolfo@axim.org>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Ihor Romaniuk
4fc41b0fe7 fix: username suggestions alignment
Port of master commit ffb8a2d4.

Co-Authored-By: Kyrylo Hudym-Levkovych <kyr.hudym@kyrs-MacBook-Pro.local>
Co-Authored-By: Adolfo R. Brandes <adolfo@axim.org>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Hassan Raza
df9454bbe6 chore: handle forbidden username exceptions on registration
Port of master commit 58ec90ac.

Co-Authored-By: Adolfo R. Brandes <adolfo@axim.org>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Diana Villalvazo
5e41382f24 refactor: replace injectIntl with useIntl
Port of master commits db3d007c, 43a584eb, 5bd6926f, and 76e400f0.

Co-Authored-By: Adolfo R. Brandes <adolfo@axim.org>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:36:34 -03:00
Adolfo R. Brandes
fe44896856 chore: bump frontend-base to 1.0.0-alpha.14 2026-03-13 16:36:34 -03:00
javier ontiveros
ed58e02eae chore: update route base path 2026-03-12 07:18:17 -03:00
Adolfo R. Brandes
2337a7105f 1.0.0-alpha.6 2026-03-04 10:58:29 -03:00
Adolfo R. Brandes
b9f2c5da43 feat!: compile to JS before publishing
Configure the package to compile TypeScript and copy SCSS files to dist/
before publishing, rather than publishing raw source files. This allows
us to use tsc-alias for @src imports.

Also use a more modern export map to decouple the internal file
structure from the package's API, and add a build step to CI.

BREAKING CHANGE: Consuming projects may need to update their imports or
SASS @use lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:02:42 -03:00
Javier Ontiveros
1a0a6e3179 feat: added code spliting on main module (#1627) 2026-02-12 16:15:08 -03:00
Adolfo R. Brandes
92208a0a7c 1.0.0-alpha.5 2026-02-09 20:21:28 -03:00
Adolfo R. Brandes
45535ee055 build: Add minimum node version warning
An issue was found when using Node version 24.9 with the latest
package-lock (see #123), one not reproducible with versions 24.12 and
above.  Add a warning that will be shown when running `npm ci`.
2026-01-15 09:49:57 -03:00
Adolfo R. Brandes
e53c4997bb fix: missing styles
The shell's SCSS must be explicitly loaded by site.config.dev.tsx.
2026-01-15 09:49:57 -03:00
Adolfo R. Brandes
f311539e12 fix: CI
App repos are no longer built.
2026-01-15 09:49:57 -03:00
Adolfo R. Brandes
c1070930bf fix: bump frontend-base
Bump frontend-base so we stop failing linting due to the nullish
coalescing rule we can't follow.
2026-01-15 09:49:57 -03:00
Adolfo R. Brandes
dad2887eed fix: linting errors 2026-01-15 09:49:57 -03:00
Adolfo R. Brandes
4f79099eca build: Upgrade to Node 24 2026-01-15 09:49:57 -03:00
Adolfo R. Brandes
49f42a8857 feat!: add design tokens support
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.
2026-01-15 09:49:57 -03:00
Adolfo R. Brandes
2d6b401312 1.0.0-alpha.4 2025-08-20 13:12:29 -03:00
Adolfo R. Brandes
c602136798 chore: bump frontend-base and regenerate package-lock 2025-08-20 13:12:08 -03:00
Adolfo R. Brandes
9a48fe323b fix: work around inotify handle starvation
Bump frontend-base to avoid the inotify handle resource starvation.
2025-07-24 11:59:18 -03:00
Adolfo R. Brandes
967c52bde9 1.0.0-alpha.3 2025-06-29 13:20:08 +02:00
Adolfo R. Brandes
3252b593fd fix: bump frontend-base
Bump frontend-base to alpha.4 to fix a bug with the route role
redirection mechanism.
2025-06-29 13:19:33 +02:00
Adolfo R. Brandes
c98b9a1408 1.0.0-alpha.2 2025-06-29 13:11:02 +02:00
Adolfo R. Brandes
b6c659ccb8 feat: redirect to route role on successful login
Now we have a way to discover app URLs dynamically; do so for the
(default) redirect on login.
2025-06-29 13:09:17 +02:00
Adolfo R. Brandes
7efa279a29 1.0.0-alpha.1 2025-06-28 22:16:19 +02:00
Adolfo R. Brandes
f83f24af89 chore: bump frontend-base 2025-06-28 22:16:07 +02:00
Adolfo R. Brandes
b8116485b0 v1.0.0-alpha.0 2025-06-28 19:31:31 +02:00
Adolfo R. Brandes
762ff75fc4 Merge pull request #1515 from arbrandes/frontend-base-fixes
feat: Prepare for publication to NPM
2025-06-28 14:28:54 -03:00
Adolfo R. Brandes
095b156b95 chore: bump frontend-base 2025-06-28 19:27:26 +02:00
Adolfo R. Brandes
033e0fd7c5 feat: handle authentication roles 2025-06-28 19:18:34 +02:00
Adolfo R. Brandes
1ecdb0b6af chore: gitignore npm pack artifacts 2025-06-26 23:05:50 -03:00
Adolfo R. Brandes
18951cc4d0 chore: clean up npmignore 2025-06-26 23:05:50 -03:00
Adolfo R. Brandes
4dd5ddcc8b refactor: use appId from constants 2025-06-26 23:05:50 -03:00
Adolfo R. Brandes
6acbf64a71 chore: prepare for publication
Update package.json for publication as a "buildless" library.

(Also upgrade openedx-atlas.)
2025-06-26 19:52:56 -03:00
Adolfo R. Brandes
aea12a6a37 fix: remove catch-all route
Apps should avoid having a catch-all route, instead relying on the shell
to provide it.
2025-06-26 19:46:12 -03:00
Adolfo R. Brandes
3d778807f1 fix: update index.html
The only purpose of the included index.html is to serve the dev
environment, so modify it to make that clear.
2025-06-26 19:28:02 -03:00
Adolfo R. Brandes
014a990c22 docs: remove reference to devstack 2025-06-26 19:27:40 -03:00
Adolfo R. Brandes
7c8051a440 test: fix a couple of tests 2025-06-26 19:27:23 -03:00
Adolfo R. Brandes
8f8531a242 refactor: migrate to frontend-base
BREAKING CHANGE: refactors the MFE for frontend-base.
2025-06-24 15:30:07 -03:00
198 changed files with 6857 additions and 16243 deletions

45
.env
View File

@@ -1,45 +0,0 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=''
SITE_NAME=null
INFO_EMAIL=''
# ***** Cookies *****
USER_RETENTION_COOKIE_NAME=null
# ***** Links *****
LOGIN_ISSUE_SUPPORT_LINK=''
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK=null
POST_REGISTRATION_REDIRECT_URL=''
SEARCH_CATALOG_URL=''
# ***** Features flags *****
DISABLE_ENTERPRISE_LOGIN=''
ENABLE_AUTO_GENERATED_USERNAME=''
ENABLE_DYNAMIC_REGISTRATION_FIELDS=''
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN=''
ENABLE_POST_REGISTRATION_RECOMMENDATIONS=''
MARKETING_EMAILS_OPT_IN=''
SHOW_CONFIGURABLE_EDX_FIELDS=''
ENABLE_IMAGE_LAYOUT=''
# ***** Zendesk related keys *****
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
# ***** Base Container Images *****
BANNER_IMAGE_LARGE=''
BANNER_IMAGE_MEDIUM=''
BANNER_IMAGE_SMALL=''
BANNER_IMAGE_EXTRA_SMALL=''
# ***** Miscellaneous *****
APP_ID=''
MFE_CONFIG_API_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -41,5 +41,3 @@ APP_ID=''
MFE_CONFIG_API_URL=''
ZENDESK_KEY=''
ZENDESK_LOGO_URL=''
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -1,4 +0,0 @@
# Copy these to the .env.private to enable edX specific functionality on local system
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='true'
MARKETING_EMAILS_OPT_IN='true'
SHOW_CONFIGURABLE_EDX_FIELDS='true'

View File

@@ -1,21 +0,0 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:1995'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME='Your Platform Name Here'
APP_ID=''
MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}

View File

@@ -1,6 +0,0 @@
coverage/*
dist/
docs
node_modules/
__mocks__/
__snapshots__/

View File

@@ -1,52 +0,0 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('eslint', {
rules: {
// Temporarily update the 'indent', 'template-curly-spacing' and
// 'no-multiple-empty-lines' rules since they are causing eslint
// to fail for no apparent reason since upgrading
// @openedx/frontend-build from v3 to v5:
// - TypeError: Cannot read property 'range' of null
indent: [
'error',
2,
{ ignoredNodes: ['TemplateLiteral', 'SwitchCase'] },
],
'template-curly-spacing': 'off',
'jsx-a11y/label-has-associated-control': ['error', {
labelComponents: [],
labelAttributes: [],
controlComponents: [],
assert: 'htmlFor',
depth: 25,
}],
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: true }],
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
['sibling', 'parent'],
'index',
],
pathGroups: [
{
pattern: '@(react|react-dom|react-redux)',
group: 'external',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'function-paren-newline': 'off',
},
});

16
.gitignore vendored
View File

@@ -1,21 +1,15 @@
.DS_Store
.eslintcache
.idea
node_modules
npm-debug.log
coverage
module.config.js
.env.private
dist/
/*.tgz
### i18n ###
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
### pyenv ###
.python-version
### Emacs ###
### Editors ###
.DS_Store
*~
/temp
/.vscode
src/i18n/messages

View File

@@ -1,11 +1,6 @@
.eslintignore
.eslintrc.json
.gitignore
docker-compose.yml
Dockerfile
Makefile
npm-debug.log
coverage
__mocks__
node_modules
public
*.test.js
*.test.jsx
*.test.ts
*.test.tsx

2
CODEOWNERS Normal file
View File

@@ -0,0 +1,2 @@
# The following users are the owners of all frontend-app-authn files
* @openedx/2u-infinity

View File

@@ -13,6 +13,19 @@ precommit:
requirements:
npm ci
clean:
rm -rf dist
build: clean
tsc --project tsconfig.build.json
tsc-alias -p tsconfig.build.json
find src -type f -name '*.scss' -exec sh -c '\
for f in "$$@"; do \
d="dist/$${f#src/}"; \
mkdir -p "$$(dirname "$$d")"; \
cp "$$f" "$$d"; \
done' sh {} +
i18n.extract:
# Pulling display strings from .jsx files into .json files...
rm -rf $(transifex_temp)

View File

@@ -26,49 +26,14 @@ This is a micro-frontend application responsible for the login, registration and
Getting Started
***************
Prerequisites
=============
Installation
============
`Tutor`_ is currently recommended as a development environment for your new MFE. Please refer to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
Cloning and Startup
===================
1. Clone your new repo:
.. code-block:: bash
git clone https://github.com/edx/frontend-app-authn.git
2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes a ``.nvmrc`` file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
.. code-block:: bash
cd frontend-app-authn && npm install
4. Update the application port to use for local development:
The default port is 1999. If this does not work for you, update the line
``PORT=1999`` to your port in all ``.env.*`` files
5. Start the devserver. The app will be running at ``localhost:1999``, or whatever port you change it too.
.. code-block:: bash
npm run dev
**Note:** Follow `Enable social auth locally <docs/how_tos/enable_social_auth.rst>`_ for enabling Social Sign-on Buttons (SSO) locally
Environment Variables/Setup Notes
=================================
@@ -130,7 +95,7 @@ The authentication micro-frontend also requires the following additional variabl
* - ``MFE_CONFIG_API_URL``
- Link of the API to get runtime mfe configuration variables from the site configuration or django settings.
- ``/api/v1/mfe_config`` | ``''`` (empty strings are falsy)
- ``/api/v1/mfe_config`` | ``''`` (empty strings are falsy)
* - ``APP_ID``
- Name of MFE, this will be used by the API to get runtime configurations for the specific micro frontend. For a frontend repo `frontend-app-appName`, use `appName` as APP_ID.
@@ -158,10 +123,6 @@ Furthermore, there are several edX-specific environment variables that enable in
- Enables support for opting in marketing emails that helps us getting user consent for sending marketing emails.
- ``true`` | ``''`` (empty strings are falsy)
* - ``SHOW_CONFIGURABLE_EDX_FIELDS``
- For edX, country and honor code fields are required by default. This flag enables edX specific required fields.
- ``true`` | ``''`` (empty strings are falsy)
For more information see the document: `Micro-frontend applications in Open
edX <https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development>`__.
@@ -240,4 +201,4 @@ Please see `LICENSE <https://github.com/openedx/frontend-app-authn/blob/master/L
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
:alt: Continuous Integration
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release
:target: https://github.com/semantic-release/semantic-release

5
app.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="@openedx/frontend-base" />
declare module 'site.config' {
export default SiteConfig;
}

3
babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
const { createConfig } = require('@openedx/frontend-base/tools');
module.exports = createConfig('babel');

22
eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
// @ts-check
const { createLintConfig } = require('@openedx/frontend-base/tools');
module.exports = createLintConfig(
{
files: [
'src/**/*',
'site.config.*',
],
},
{
ignores: [
'coverage/*',
'dist/*',
'docs/*',
'node_modules/*',
'**/__mocks__/*',
'**/__snapshots__/*',
],
},
);

View File

@@ -1,14 +1,15 @@
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@openedx/frontend-base/tools');
module.exports = createConfig('jest', {
setupFiles: [
module.exports = createConfig('test', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [
'src/setupTest.js',
'src/i18n',
'src/index.jsx',
'MainApp.jsx',
],
testEnvironment: 'jsdom',
moduleNameMapper: {
'\\.svg$': '<rootDir>/src/__mocks__/svg.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/src/__mocks__/file.js',
},
});

16397
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,40 @@
{
"name": "@edx/frontend-app-authn",
"version": "0.1.0",
"description": "Frontend application template",
"name": "@openedx/frontend-app-authn",
"version": "1.0.0-alpha.6",
"description": "Frontend authentication",
"engines": {
"node": "^24.12"
},
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-authn.git"
},
"exports": {
".": "./dist/index.js",
"./app.scss": "./dist/app.scss"
},
"files": [
"/dist"
],
"browserslist": [
"extends @edx/browserslist-config"
],
"sideEffects": [
"*.css",
"*.scss"
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/authn/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "fedx-scripts jest --coverage --passWithNoTests"
"build": "make build",
"clean": "make clean",
"dev": "PORT=1999 PUBLIC_PATH=/authn openedx dev",
"i18n_extract": "openedx formatjs extract",
"lint": "openedx lint .",
"lint:fix": "openedx lint --fix .",
"prepack": "npm run build",
"snapshot": "openedx test --updateSnapshot",
"test": "openedx test --coverage --passWithNoTests"
},
"author": "edX",
"author": "Open edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-authn#readme",
"publishConfig": {
@@ -29,48 +44,41 @@
"url": "https://github.com/openedx/frontend-app-authn/issues"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-brands-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.6",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.2",
"@optimizely/react-sdk": "^2.9.1",
"@tanstack/react-query": "^5.90.19",
"@testing-library/react": "^16.2.0",
"algoliasearch": "^4.14.3",
"algoliasearch-helper": "^3.26.0",
"classnames": "2.5.1",
"core-js": "3.43.0",
"fastest-levenshtein": "1.0.16",
"form-urlencoded": "6.1.6",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "6.1.0",
"react-loading-skeleton": "3.5.0",
"react-responsive": "8.2.0",
"react-router": "6.30.3",
"react-router-dom": "6.30.3",
"react-zendesk": "^0.1.13",
"regenerator-runtime": "0.14.1",
"universal-cookie": "7.2.2"
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"classnames": "^2.5.1",
"fastest-levenshtein": "^1.0.16",
"form-urlencoded": "^6.1.5",
"i18n-iso-countries": "^7.13.0",
"prop-types": "^15.8.1",
"query-string": "^7.1.3",
"react-helmet": "^6.1.0",
"react-loading-skeleton": "^3.5.0",
"react-responsive": "^8.2.0",
"universal-cookie": "^8.0.1"
},
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "^6.9.1",
"babel-plugin-formatjs": "10.5.41",
"eslint-plugin-import": "2.32.0",
"glob": "7.2.3",
"history": "5.3.0",
"jest": "30.3.0",
"@edx/browserslist-config": "^1.5.0",
"@testing-library/react": "^16.3.0",
"@types/jest": "^29.5.14",
"babel-plugin-formatjs": "10.5.38",
"eslint-plugin-import": "2.31.0",
"jest": "^29.7.0",
"react-test-renderer": "^18.3.1",
"ts-jest": "^29.4.0"
"ts-jest": "^29.4.0",
"tsc-alias": "^1.8.16"
},
"peerDependencies": {
"@openedx/frontend-base": "^1.0.0-alpha.14",
"@openedx/paragon": "^23",
"@tanstack/react-query": "^5",
"react": "^18",
"react-dom": "^18",
"react-router": "^6",
"react-router-dom": "^6"
}
}

View File

@@ -1,24 +1,9 @@
<!doctype html>
<html lang="en-us">
<head>
<title><%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %></title>
<title>Authentication Development Site></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.4.4/iframeResizer.contentWindow.min.js"
integrity="sha512-IWwZFBvHzN41wNI6etRLLuLrDDj/6AwJcPt7cmKJAzluYTIHHQ1PF8wh0rSy05jxEvvjflVvH2MxeV6riyEEXg=="
crossorigin="anonymous"
referrerpolicy="no-referrer">
</script>
<% if (process.env.OPTIMIZELY_URL) { %>
<script
src="<%= process.env.OPTIMIZELY_URL %>"
></script>
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
<script
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
></script>
<% } %>
</head>
<body>
<div id="root"></div>

19
site.config.dev.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { EnvironmentTypes, SiteConfig } from '@openedx/frontend-base';
import { authnApp } from './src';
import './src/app.scss';
const siteConfig: SiteConfig = {
siteId: 'authn-dev',
siteName: 'Authn Dev',
baseUrl: 'http://apps.local.openedx.io:8080',
lmsBaseUrl: 'http://local.openedx.io:8000',
loginUrl: 'http://local.openedx.io:8000/login',
logoutUrl: 'http://local.openedx.io:8000/logout',
environment: EnvironmentTypes.DEVELOPMENT,
apps: [authnApp],
};
export default siteConfig;

52
site.config.test.tsx Normal file
View File

@@ -0,0 +1,52 @@
import type { SiteConfig } from '@openedx/frontend-base';
import { appId } from './src/constants';
const siteConfig: SiteConfig = {
siteId: 'test-site',
siteName: 'Test Site',
baseUrl: 'http://localhost:1996',
lmsBaseUrl: 'http://localhost:8000',
loginUrl: 'http://localhost:8000/login',
logoutUrl: 'http://localhost:8000/logout',
// Use 'test' instead of EnvironmentTypes.TEST to break a circular dependency
// when mocking `@openedx/frontend-base` itself.
environment: 'test' as SiteConfig['environment'],
apps: [{
appId,
config: {
ACTIVATION_EMAIL_SUPPORT_LINK: null,
ALLOW_PUBLIC_ACCOUNT_CREATION: false,
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: null,
BANNER_IMAGE_EXTRA_SMALL: '',
BANNER_IMAGE_LARGE: '',
BANNER_IMAGE_MEDIUM: '',
BANNER_IMAGE_SMALL: '',
DISABLE_ENTERPRISE_LOGIN: true,
ENABLE_AUTO_GENERATED_USERNAME: false,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: false,
ENABLE_IMAGE_LAYOUT: false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: false,
FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico',
INFO_EMAIL: '',
LOGIN_ISSUE_SUPPORT_LINK: null,
LOGO_TRADEMARK_URL: 'https://edx-cdn.org/v3/default/logo-trademark.svg',
LOGO_URL: 'https://edx-cdn.org/v3/default/logo.svg',
LOGO_WHITE_URL: 'https://edx-cdn.org/v3/default/logo-white.svg',
MARKETING_EMAILS_OPT_IN: '',
MARKETING_SITE_BASE_URL: 'http://localhost:18000',
PASSWORD_RESET_SUPPORT_LINK: null,
POST_REGISTRATION_REDIRECT_URL: '',
PRIVACY_POLICY: null,
SEARCH_CATALOG_URL: null,
SESSION_COOKIE_DOMAIN: 'local.openedx.io',
SHOW_REGISTRATION_LINKS: false,
TOS_AND_HONOR_CODE: null,
TOS_LINK: null,
USER_RETENTION_COOKIE_NAME: '',
},
}],
};
export default siteConfig;

19
src/Main.tsx Executable file
View File

@@ -0,0 +1,19 @@
import { Outlet } from 'react-router-dom';
import { CurrentAppProvider } from '@openedx/frontend-base';
import { appId } from './constants';
import {
registerIcons,
} from './common-components';
import './sass/_style.scss';
registerIcons();
const Main = () => (
<CurrentAppProvider appId={appId}>
<Outlet />
</CurrentAppProvider>
);
export default Main;

View File

@@ -1,76 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Helmet } from 'react-helmet';
import { Navigate, Route, Routes } from 'react-router-dom';
import {
EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk,
} from './common-components';
import {
AUTHN_PROGRESSIVE_PROFILING,
LOGIN_PAGE,
PAGE_NOT_FOUND,
PASSWORD_RESET_CONFIRM,
RECOMMENDATIONS,
REGISTER_EMBEDDED_PAGE,
REGISTER_PAGE,
RESET_PAGE,
} from './data/constants';
import { updatePathWithQueryParams } from './data/utils';
import { ForgotPasswordPage } from './forgot-password';
import Logistration from './logistration/Logistration';
import { ProgressiveProfiling } from './progressive-profiling';
import { RecommendationsPage } from './recommendations';
import { RegistrationPage } from './register';
import { ResetPasswordPage } from './reset-password';
import './index.scss';
registerIcons();
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
},
});
const MainApp = () => (
<QueryClientProvider client={queryClient}>
<AppProvider>
<Helmet>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
{getConfig().ZENDESK_KEY && <Zendesk />}
<Routes>
<Route path="/" element={<Navigate replace to={updatePathWithQueryParams(REGISTER_PAGE)} />} />
<Route
path={REGISTER_EMBEDDED_PAGE}
element={<EmbeddedRegistrationRoute><RegistrationPage /></EmbeddedRegistrationRoute>}
/>
<Route
path={LOGIN_PAGE}
element={
<UnAuthOnlyRoute><Logistration selectedPage={LOGIN_PAGE} /></UnAuthOnlyRoute>
}
/>
<Route
path={REGISTER_PAGE}
element={
<UnAuthOnlyRoute><Logistration selectedPage={REGISTER_PAGE} /></UnAuthOnlyRoute>
}
/>
<Route path={RESET_PAGE} element={<UnAuthOnlyRoute><ForgotPasswordPage /></UnAuthOnlyRoute>} />
<Route path={PASSWORD_RESET_CONFIRM} element={<ResetPasswordPage />} />
<Route path={AUTHN_PROGRESSIVE_PROFILING} element={<ProgressiveProfiling />} />
<Route path={RECOMMENDATIONS} element={<RecommendationsPage />} />
<Route path={PAGE_NOT_FOUND} element={<NotFoundPage />} />
<Route path="*" element={<Navigate replace to={PAGE_NOT_FOUND} />} />
</Routes>
</AppProvider>
</QueryClientProvider>
);
export default MainApp;

1
src/__mocks__/file.js Normal file
View File

@@ -0,0 +1 @@
module.exports = 'FileMock';

1
src/__mocks__/svg.js Normal file
View File

@@ -0,0 +1 @@
module.exports = 'SvgURL';

2
src/app.scss Executable file
View File

@@ -0,0 +1,2 @@
@use "@openedx/frontend-base/shell/app.scss";
@use "./sass/style";

43
src/app.ts Normal file
View File

@@ -0,0 +1,43 @@
import { App } from '@openedx/frontend-base';
import { appId } from './constants';
import routes from './routes';
import messages from './i18n';
const app: App = {
appId,
routes,
messages,
config: {
ACTIVATION_EMAIL_SUPPORT_LINK: null,
ALLOW_PUBLIC_ACCOUNT_CREATION: true,
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: null,
BANNER_IMAGE_EXTRA_SMALL: '',
BANNER_IMAGE_LARGE: '',
BANNER_IMAGE_MEDIUM: '',
BANNER_IMAGE_SMALL: '',
DISABLE_ENTERPRISE_LOGIN: true,
ENABLE_AUTO_GENERATED_USERNAME: false,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: false,
ENABLE_IMAGE_LAYOUT: false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: false,
FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico',
INFO_EMAIL: '',
LOGIN_ISSUE_SUPPORT_LINK: null,
LOGO_TRADEMARK_URL: 'https://edx-cdn.org/v3/default/logo-trademark.svg',
LOGO_URL: 'https://edx-cdn.org/v3/default/logo.svg',
LOGO_WHITE_URL: 'https://edx-cdn.org/v3/default/logo-white.svg',
MARKETING_EMAILS_OPT_IN: '',
MARKETING_SITE_BASE_URL: 'http://local.openedx.io',
PASSWORD_RESET_SUPPORT_LINK: null,
POST_REGISTRATION_REDIRECT_URL: '',
PRIVACY_POLICY: null,
SEARCH_CATALOG_URL: null,
SESSION_COOKIE_DOMAIN: 'local.openedx.io',
SHOW_REGISTRATION_LINKS: true,
TOS_AND_HONOR_CODE: null,
TOS_LINK: null,
USER_RETENTION_COOKIE_NAME: '',
},
};
export default app;

View File

@@ -1,4 +1,4 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@openedx/frontend-base';
import { render, screen } from '@testing-library/react';
import { DefaultLargeLayout, DefaultMediumLayout, DefaultSmallLayout } from './index';

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
@@ -11,20 +10,20 @@ const LargeLayout = () => {
return (
<div className="w-50 d-flex">
<div className="col-md-9 bg-primary-400">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className={classNames({ 'large-yellow-line mr-n4.5': getConfig().SITE_NAME === 'edX' })} />
<div className={classNames({ 'large-yellow-line mr-n4.5': getSiteConfig().siteName === 'edX' })} />
<h1
className={classNames(
'display-2 text-white mw-xs',
{ 'ml-6': getConfig().SITE_NAME !== 'edX' },
{ 'ml-6': getSiteConfig().siteName !== 'edX' },
)}
>
{formatMessage(messages['start.learning'])}
<div className="text-accent-a">
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</div>
</h1>
</div>

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
@@ -13,22 +12,22 @@ const MediumLayout = () => {
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-primary-400">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image alt={getConfig().SITE_NAME} className="logo" src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image alt={getSiteConfig().siteName} className="logo" src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ">
<div className={classNames({ 'mt-1 medium-yellow-line': getConfig().SITE_NAME === 'edX' })} />
<div className={classNames({ 'mt-1 medium-yellow-line': getSiteConfig().siteName === 'edX' })} />
<div>
<h1
className={classNames(
'display-1 text-white mt-5 mb-5 mr-2 main-heading',
{ 'ml-4.5': getConfig().SITE_NAME !== 'edX' },
{ 'ml-4.5': getSiteConfig().siteName !== 'edX' },
)}
>
<span>
{formatMessage(messages['start.learning'])}{' '}
<span className="text-accent-a d-inline-block">
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</span>
</span>
</h1>

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import classNames from 'classnames';
@@ -12,11 +11,11 @@ const SmallLayout = () => {
<span className="bg-primary-400 w-100">
<div className="col-md-12 small-screen-top-stripe" />
<div>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="d-flex align-items-center m-3.5">
<div className={classNames({ 'small-yellow-line mr-n2.5': getConfig().SITE_NAME === 'edX' })} />
<div className={classNames({ 'small-yellow-line mr-n2.5': getSiteConfig().siteName === 'edX' })} />
<h1
className={classNames(
'text-white mt-3.5 mb-3.5',
@@ -25,7 +24,7 @@ const SmallLayout = () => {
<span>
{formatMessage(messages['start.learning'])}{' '}
<span className="text-accent-a d-inline-block">
{formatMessage(messages['with.site.name'], { siteName: getConfig().SITE_NAME })}
{formatMessage(messages['with.site.name'], { siteName: getSiteConfig().siteName })}
</span>
</span>
</h1>

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'start.learning': {

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import messages from './messages';
@@ -12,10 +9,10 @@ const ExtraSmallLayout = () => {
return (
<span
className="w-100 bg-primary-500 banner__image extra-small-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_EXTRA_SMALL})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_EXTRA_SMALL})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-4.5 mr-1 pb-3.5 pt-3.5">
<h1 className="banner__heading">

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import './index.scss';
@@ -13,10 +10,10 @@ const LargeLayout = () => {
return (
<div
className="w-50 bg-primary-500 banner__image large-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_LARGE})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_LARGE})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="min-vh-100 p-5 d-flex align-items-end">
<h1 className="display-2 mw-sm mb-3 d-flex flex-column flex-shrink-0 justify-content-center">

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import './index.scss';
@@ -13,10 +10,10 @@ const MediumLayout = () => {
return (
<div
className="w-100 mb-3 bg-primary-500 banner__image medium-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_MEDIUM})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_MEDIUM})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-5 pb-4 pt-4">
<h1 className="display-2 banner__heading">

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import messages from './messages';
@@ -12,10 +9,10 @@ const SmallLayout = () => {
return (
<span
className="w-100 bg-primary-500 banner__image small-layout"
style={{ backgroundImage: `url(${getConfig().BANNER_IMAGE_SMALL})` }}
style={{ backgroundImage: `url(${useAppConfig().BANNER_IMAGE_SMALL})` }}
>
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_WHITE_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="company-logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_WHITE_URL} />
</Hyperlink>
<div className="ml-5 mr-1 pb-3.5 pt-3.5">
<h1 className="display-2">

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'your.career.turning.point': {

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -11,14 +10,14 @@ const LargeLayout = ({ fullName }) => {
return (
<div className="w-50 d-flex">
<div className="col-md-10 bg-light-200 p-0">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo position-absolute" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="min-vh-100 d-flex align-items-center">
<div className="large-screen-left-container mr-n4.5 large-yellow-line mt-5" />
<div>
<h1 className="welcome-to-platform data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="complete-your-profile">
{formatMessage(messages['complete.your.profile.1'])}

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -13,14 +12,14 @@ const MediumLayout = ({ fullName }) => {
<div className="w-100 medium-screen-top-stripe" />
<div className="w-100 p-0 mb-3 d-flex">
<div className="col-md-10 bg-light-200">
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="d-flex align-items-center justify-content-center mb-4 ml-5">
<div className="medium-yellow-line mt-5 mr-n2" />
<div>
<h1 className="h3 data-hj-suppress mw-320">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="display-1">
{formatMessage(messages['complete.your.profile.1'])}

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Hyperlink, Image } from '@openedx/paragon';
import PropTypes from 'prop-types';
@@ -11,14 +10,14 @@ const SmallLayout = ({ fullName }) => {
return (
<div className="min-vw-100 bg-light-200">
<div className="col-md-12 small-screen-top-stripe" />
<Hyperlink destination={getConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getConfig().SITE_NAME} src={getConfig().LOGO_URL} />
<Hyperlink destination={useAppConfig().MARKETING_SITE_BASE_URL}>
<Image className="logo-small" alt={getSiteConfig().siteName} src={useAppConfig().LOGO_URL} />
</Hyperlink>
<div className="d-flex align-items-center m-3.5">
<div className="small-yellow-line mt-4.5" />
<div>
<h1 className="h5 data-hj-suppress">
{formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })}
{formatMessage(messages['welcome.to.platform'], { siteName: getSiteConfig().siteName, fullName })}
</h1>
<h2 className="h1">
{formatMessage(messages['complete.your.profile.1'])}

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'welcome.to.platform': {

View File

@@ -1,4 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useAppConfig } from '@openedx/frontend-base';
import { breakpoints } from '@openedx/paragon';
import classNames from 'classnames';
import PropTypes from 'prop-types';
@@ -11,7 +11,7 @@ import {
import { AuthLargeLayout, AuthMediumLayout, AuthSmallLayout } from './components/welcome-page-layout';
const BaseContainer = ({ children, showWelcomeBanner, fullName }) => {
const enableImageLayout = getConfig().ENABLE_IMAGE_LAYOUT;
const enableImageLayout = useAppConfig().ENABLE_IMAGE_LAYOUT;
if (enableImageLayout) {
return (

View File

@@ -1,9 +1,9 @@
import { mergeConfig } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider, mergeAppConfig } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { Context as ResponsiveContext } from 'react-responsive';
import BaseContainer from '../index';
import { appId } from '../../constants';
const LargeScreen = {
wrappingComponent: ResponsiveContext.Provider,
@@ -26,7 +26,7 @@ describe('Base component tests', () => {
});
it('renders Image layout when ENABLE_IMAGE_LAYOUT configuration is enabled', () => {
mergeConfig({
mergeAppConfig(appId, {
ENABLE_IMAGE_LAYOUT: true,
});

View File

@@ -1,6 +1,5 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import {
Button, Form,
Icon,
@@ -8,8 +7,8 @@ import {
import { Login } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
import messages from './messages';
/**
* This component renders the Single sign-on (SSO) button only for the tpa provider passed
@@ -17,12 +16,12 @@ import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
const EnterpriseSSO = (props) => {
const { formatMessage } = useIntl();
const tpaProvider = props.provider;
const hideRegistrationLink = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false
|| getConfig().SHOW_REGISTRATION_LINKS === false;
const hideRegistrationLink = useAppConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false
|| useAppConfig().SHOW_REGISTRATION_LINKS === false;
const handleSubmit = (e, url) => {
e.preventDefault();
window.location.href = getConfig().LMS_BASE_URL + url;
window.location.href = getSiteConfig().lmsBaseUrl + url;
};
const handleClick = (e) => {
@@ -48,7 +47,7 @@ const EnterpriseSSO = (props) => {
{tpaProvider.iconImage ? (
<div aria-hidden="true">
<img className="btn-tpa__image-icon" src={tpaProvider.iconImage} alt={`icon ${tpaProvider.name}`} />
<span className="pl-2" aria-hidden="true">{ tpaProvider.name }</span>
<span className="pl-2" aria-hidden="true">{tpaProvider.name}</span>
</div>
)
: (
@@ -60,7 +59,7 @@ const EnterpriseSSO = (props) => {
<Icon className="h-75" src={Login} />
)}
</div>
<span className="pl-2" aria-hidden="true">{ tpaProvider.name }</span>
<span className="pl-2" aria-hidden="true">{tpaProvider.name}</span>
</>
)}
</Button>

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Button, Hyperlink, Icon } from '@openedx/paragon';
import { Institution } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
@@ -29,7 +28,7 @@ export const RenderInstitutionButton = props => {
* This component renders the page list of available institutions for login
* */
const InstitutionLogistration = props => {
const lmsBaseUrl = getConfig().LMS_BASE_URL;
const lmsBaseUrl = getSiteConfig().lmsBaseUrl;
const { formatMessage } = useIntl();
const {
secondaryProviders,

View File

@@ -1,4 +1,4 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FormattedMessage } from '@openedx/frontend-base';
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useIntl } from '@openedx/frontend-base';
import {
Form, Icon, IconButton, OverlayTrigger, Tooltip, useToggle,
} from '@openedx/paragon';
@@ -9,11 +9,11 @@ import {
} from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants';
import { useRegisterContext } from '../register/components/RegisterContext';
import { useFieldValidations } from '../register/data/apiHook';
import { validatePasswordField } from '../register/data/utils';
import messages from './messages';
const noopFn = () => {};
const PasswordField = (props) => {
const { formatMessage } = useIntl();
@@ -21,20 +21,10 @@ const PasswordField = (props) => {
const [showTooltip, setShowTooltip] = useState(false);
const {
setValidationsSuccess,
setValidationsFailure,
validationApiRateLimited,
clearRegistrationBackendError,
} = useRegisterContext();
const fieldValidationsMutation = useFieldValidations({
onSuccess: (data) => {
setValidationsSuccess(data);
},
onError: () => {
setValidationsFailure();
},
});
validationApiRateLimited = false,
clearRegistrationBackendError = noopFn,
validateField = noopFn,
} = props;
const handleBlur = (e) => {
const { name, value } = e.target;
@@ -63,7 +53,7 @@ const PasswordField = (props) => {
if (fieldError) {
props.handleErrorChange('password', fieldError);
} else if (!validationApiRateLimited) {
fieldValidationsMutation.mutate({ password: passwordValue });
validateField({ password: passwordValue });
}
}
};
@@ -168,6 +158,9 @@ PasswordField.defaultProps = {
showRequirements: true,
showScreenReaderText: true,
autoComplete: null,
clearRegistrationBackendError: noopFn,
validateField: noopFn,
validationApiRateLimited: false,
};
PasswordField.propTypes = {
@@ -183,6 +176,9 @@ PasswordField.propTypes = {
value: PropTypes.string.isRequired,
autoComplete: PropTypes.string,
showScreenReaderText: PropTypes.bool,
clearRegistrationBackendError: PropTypes.func,
validateField: PropTypes.func,
validationApiRateLimited: PropTypes.bool,
};
export default PasswordField;

View File

@@ -1,9 +1,9 @@
import { getConfig } from '@edx/frontend-platform';
import { useAppConfig, getSiteConfig } from '@openedx/frontend-base';
import PropTypes from 'prop-types';
import { Navigate } from 'react-router-dom';
import {
AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS, REDIRECT,
AUTHN_PROGRESSIVE_PROFILING, REDIRECT,
} from '../data/constants';
import { setCookie } from '../data/utils';
@@ -15,20 +15,20 @@ const RedirectLogistration = (props) => {
redirectToProgressiveProfilingPage,
success,
optionalFields,
redirectToRecommendationsPage,
educationLevel,
userId,
registrationEmbedded,
host,
} = props;
let finalRedirectUrl = '';
if (success) {
// If we're in a third party auth pipeline, we must complete the pipeline
// once user has successfully logged in. Otherwise, redirect to the specified redirect url.
// Note: For multiple enterprise use case, we need to make sure that user first visits the
// enterprise selection page and then complete the auth workflow
if (finishAuthUrl && !redirectUrl.includes(finishAuthUrl)) {
finalRedirectUrl = getConfig().LMS_BASE_URL + finishAuthUrl;
finalRedirectUrl = getSiteConfig().lmsBaseUrl + finishAuthUrl;
} else {
finalRedirectUrl = redirectUrl;
}
@@ -36,12 +36,12 @@ const RedirectLogistration = (props) => {
// Redirect to Progressive Profiling after successful registration
if (redirectToProgressiveProfilingPage) {
// TODO: Do we still need this cookie?
setCookie('van-504-returning-user', true);
setCookie('van-504-returning-user', true, useAppConfig().SESSION_COOKIE_DOMAIN);
if (registrationEmbedded) {
window.parent.postMessage({
action: REDIRECT,
redirectUrl: getConfig().POST_REGISTRATION_REDIRECT_URL,
redirectUrl: useAppConfig().POST_REGISTRATION_REDIRECT_URL,
}, host);
return null;
}
@@ -59,22 +59,6 @@ const RedirectLogistration = (props) => {
);
}
// Redirect to Recommendation page
if (redirectToRecommendationsPage) {
const registrationResult = { redirectUrl: finalRedirectUrl, success };
return (
<Navigate
to={RECOMMENDATIONS}
state={{
registrationResult,
educationLevel,
userId,
}}
replace
/>
);
}
window.location.href = finalRedirectUrl;
}
@@ -89,7 +73,6 @@ RedirectLogistration.defaultProps = {
redirectUrl: '',
redirectToProgressiveProfilingPage: false,
optionalFields: {},
redirectToRecommendationsPage: false,
userId: null,
registrationEmbedded: false,
host: '',
@@ -103,7 +86,6 @@ RedirectLogistration.propTypes = {
redirectUrl: PropTypes.string,
redirectToProgressiveProfilingPage: PropTypes.bool,
optionalFields: PropTypes.shape({}),
redirectToRecommendationsPage: PropTypes.bool,
userId: PropTypes.number,
registrationEmbedded: PropTypes.bool,
host: PropTypes.string,

View File

@@ -1,12 +1,11 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Icon } from '@openedx/paragon';
import { Login } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants';
import messages from './messages';
const SocialAuthProviders = (props) => {
const { formatMessage } = useIntl();
@@ -16,7 +15,7 @@ const SocialAuthProviders = (props) => {
e.preventDefault();
const url = e.currentTarget.dataset.providerUrl;
window.location.href = getConfig().LMS_BASE_URL + url;
window.location.href = getSiteConfig().lmsBaseUrl + url;
}
const socialAuth = socialAuthProviders.map((provider, index) => (

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base';
import {
Hyperlink, Icon,
} from '@openedx/paragon';
@@ -8,10 +7,10 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import Skeleton from 'react-loading-skeleton';
import messages from './messages';
import {
ENTERPRISE_LOGIN_URL, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE,
} from '../data/constants';
import messages from './messages';
import {
RenderInstitutionButton,
@@ -33,8 +32,8 @@ const ThirdPartyAuth = (props) => {
} = props;
const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider;
const isSocialAuthActive = !!providers.length && !currentProvider;
const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN;
const enterpriseLoginURL = getConfig().LMS_BASE_URL + ENTERPRISE_LOGIN_URL;
const isEnterpriseLoginDisabled = useAppConfig().DISABLE_ENTERPRISE_LOGIN;
const enterpriseLoginURL = getSiteConfig().lmsBaseUrl + ENTERPRISE_LOGIN_URL;
const isThirdPartyAuthActive = isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive);
return (

View File

@@ -1,15 +1,14 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import { Alert } from '@openedx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants';
import messages from './messages';
const ThirdPartyAuthAlert = (props) => {
const { formatMessage } = useIntl();
const { currentProvider, referrer } = props;
const platformName = getConfig().SITE_NAME;
const platformName = getSiteConfig().siteName;
let message;
if (referrer === LOGIN_PAGE) {
@@ -28,7 +27,7 @@ const ThirdPartyAuthAlert = (props) => {
{referrer === REGISTER_PAGE ? (
<Alert.Heading>{formatMessage(messages['tpa.alert.heading'])}</Alert.Heading>
) : null}
<p>{ message }</p>
<p>{message}</p>
</Alert>
{referrer === REGISTER_PAGE ? (
<h4 className="mt-4 mb-4">{formatMessage(messages['registration.using.tpa.form.heading'])}</h4>

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { fetchAuthenticatedUser, getAuthenticatedUser, getSiteConfig } from '@openedx/frontend-base';
import PropTypes from 'prop-types';
import {
@@ -25,7 +24,7 @@ const UnAuthOnlyRoute = ({ children }) => {
if (isReady) {
if (authUser && authUser.username) {
global.location.href = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
global.location.href = getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL);
return null;
}

View File

@@ -1,59 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import Zendesk from 'react-zendesk';
import messages from './messages';
import { REGISTER_EMBEDDED_PAGE } from '../data/constants';
const ZendeskHelp = () => {
const { formatMessage } = useIntl();
const setting = {
cookies: true,
webWidget: {
contactOptions: {
enabled: false,
},
chat: {
suppress: false,
departments: {
enabled: ['account settings', 'billing and payments', 'certificates', 'deadlines', 'errors and technical issues', 'other', 'proctoring'],
},
},
contactForm: {
ticketForms: [
{
id: 360003368814,
subject: false,
fields: [{ id: 'description', prefill: { '*': '' } }],
},
],
selectTicketForm: {
'*': formatMessage(messages.selectTicketForm),
},
attachments: true,
},
helpCenter: {
originalArticleButton: true,
},
answerBot: {
suppress: false,
contactOnlyAfterQuery: true,
title: { '*': formatMessage(messages.supportTitle) },
avatar: {
url: getConfig().ZENDESK_LOGO_URL,
name: { '*': formatMessage(messages.supportTitle) },
},
},
},
};
if (window.location.pathname === REGISTER_EMBEDDED_PAGE) {
return null;
}
return (
<Zendesk defer zendeskKey={getConfig().ZENDESK_KEY} {...setting} />
);
};
export default ZendeskHelp;

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ThirdPartyAuthProvider, useThirdPartyAuthContext } from './ThirdPartyAuthContext';
const TestComponent = () => {
@@ -29,7 +28,7 @@ describe('ThirdPartyAuthContext', () => {
</ThirdPartyAuthProvider>,
);
expect(screen.getByText('Test Child')).toBeInTheDocument();
expect(screen.getByText('Test Child')).toBeTruthy();
});
it('should provide all context values to children', () => {
@@ -39,10 +38,10 @@ describe('ThirdPartyAuthContext', () => {
</ThirdPartyAuthProvider>,
);
expect(screen.getByText('FieldDescriptions Available')).toBeInTheDocument();
expect(screen.getByText('OptionalFields Available')).toBeInTheDocument();
expect(screen.getByText('AuthApiStatus Not Available')).toBeInTheDocument(); // Initially null
expect(screen.getByText('AuthContext Available')).toBeInTheDocument();
expect(screen.getByText('FieldDescriptions Available')).toBeTruthy();
expect(screen.getByText('OptionalFields Available')).toBeTruthy();
expect(screen.getByText('AuthApiStatus Not Available')).toBeTruthy(); // Initially null
expect(screen.getByText('AuthContext Available')).toBeTruthy();
});
it('should render multiple children', () => {
@@ -54,8 +53,8 @@ describe('ThirdPartyAuthContext', () => {
</ThirdPartyAuthProvider>,
);
expect(screen.getByText('First Child')).toBeInTheDocument();
expect(screen.getByText('Second Child')).toBeInTheDocument();
expect(screen.getByText('Third Child')).toBeInTheDocument();
expect(screen.getByText('First Child')).toBeTruthy();
expect(screen.getByText('Second Child')).toBeTruthy();
expect(screen.getByText('Third Child')).toBeTruthy();
});
});

View File

@@ -5,34 +5,34 @@ import {
import { COMPLETE_STATE, FAILURE_STATE, PENDING_STATE } from '../../data/constants';
interface ThirdPartyAuthContextType {
fieldDescriptions: any;
fieldDescriptions: any,
optionalFields: {
fields: any;
extended_profile: any[];
};
thirdPartyAuthApiStatus: string | null;
fields: any,
extended_profile: any[],
},
thirdPartyAuthApiStatus: string | null,
thirdPartyAuthContext: {
platformName: string | null;
autoSubmitRegForm: boolean;
currentProvider: string | null;
finishAuthUrl: string | null;
countryCode: string | null;
providers: any[];
secondaryProviders: any[];
pipelineUserDetails: any | null;
errorMessage: string | null;
welcomePageRedirectUrl: string | null;
};
setThirdPartyAuthContextBegin: () => void;
setThirdPartyAuthContextSuccess: (fieldDescData: any, optionalFieldsData: any, contextData: any) => void;
setThirdPartyAuthContextFailure: () => void;
clearThirdPartyAuthErrorMessage: () => void;
platformName: string | null,
autoSubmitRegForm: boolean,
currentProvider: string | null,
finishAuthUrl: string | null,
countryCode: string | null,
providers: any[],
secondaryProviders: any[],
pipelineUserDetails: any | null,
errorMessage: string | null,
welcomePageRedirectUrl: string | null,
},
setThirdPartyAuthContextBegin: () => void,
setThirdPartyAuthContextSuccess: (fieldDescData: any, optionalFieldsData: any, contextData: any) => void,
setThirdPartyAuthContextFailure: () => void,
clearThirdPartyAuthErrorMessage: () => void,
}
const ThirdPartyAuthContext = createContext<ThirdPartyAuthContextType | undefined>(undefined);
interface ThirdPartyAuthProviderProps {
children: ReactNode;
children: ReactNode,
}
export const ThirdPartyAuthProvider: FC<ThirdPartyAuthProviderProps> = ({ children }) => {

View File

@@ -1,7 +1,6 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getSiteConfig } from '@openedx/frontend-base';
const getThirdPartyAuthContext = async (urlParams : string) => {
const getThirdPartyAuthContext = async (urlParams: string) => {
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
params: urlParams,
@@ -10,7 +9,7 @@ const getThirdPartyAuthContext = async (urlParams : string) => {
const { data } = await getAuthenticatedHttpClient()
.get(
`${getConfig().LMS_BASE_URL}/api/mfe_context`,
`${getSiteConfig().lmsBaseUrl}/api/mfe_context`,
requestConfig,
);
return {

View File

@@ -6,10 +6,12 @@ import { ThirdPartyAuthQueryKeys } from './queryKeys';
// Error constants
export const THIRD_PARTY_AUTH_ERROR = 'third-party-auth-error';
const useThirdPartyAuthHook = (pageId, payload) => useQuery({
queryKey: ThirdPartyAuthQueryKeys.byPage(pageId),
const useThirdPartyAuthHook = (pageId, payload, { enabled = true } = {}) => useQuery({
queryKey: ThirdPartyAuthQueryKeys.byPage(pageId, payload),
queryFn: () => getThirdPartyAuthContext(payload),
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes — TPA context is effectively static per session
enabled,
});
export {

View File

@@ -2,5 +2,5 @@ import { appId } from '../../constants';
export const ThirdPartyAuthQueryKeys = {
all: [appId, 'ThirdPartyAuth'] as const,
byPage: (pageId: string) => [appId, 'ThirdPartyAuth', pageId] as const,
byPage: (pageId: string, payload?: unknown) => [appId, 'ThirdPartyAuth', pageId, payload] as const,
};

View File

@@ -9,4 +9,3 @@ export { default as InstitutionLogistration } from './InstitutionLogistration';
export { RenderInstitutionButton } from './InstitutionLogistration';
export { default as FormGroup } from './FormGroup';
export { default as PasswordField } from './PasswordField';
export { default as Zendesk } from './Zendesk';

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
// institution login strings
@@ -85,33 +85,23 @@ const messages = defineMessages({
'login.third.party.auth.account.not.linked': {
id: 'login.third.party.auth.account.not.linked',
defaultMessage: 'You have successfully signed into {currentProvider}, but your {currentProvider} '
+ 'account does not have a linked {platformName} account. To link your accounts, '
+ 'sign in now using your {platformName} password.',
+ 'account does not have a linked {platformName} account. To link your accounts, '
+ 'sign in now using your {platformName} password.',
description: 'Message that appears on login page if user has successfully authenticated with social '
+ 'auth but no associated platform account exists',
+ 'auth but no associated platform account exists',
},
'register.third.party.auth.account.not.linked': {
id: 'register.third.party.auth.account.not.linked',
defaultMessage: 'You\'ve successfully signed into {currentProvider}! We just need a little more information '
+ 'before you start learning with {platformName}.',
+ 'before you start learning with {platformName}.',
description: 'Message that appears on register page if user has successfully authenticated with TPA '
+ 'but no associated platform account exists',
+ 'but no associated platform account exists',
},
'registration.using.tpa.form.heading': {
id: 'registration.using.tpa.form.heading',
defaultMessage: 'Finish creating your account',
description: 'Heading that appears above form when user is trying to create account using social auth',
},
supportTitle: {
id: 'zendesk.supportTitle',
description: 'Title for the support button',
defaultMessage: 'edX Support',
},
selectTicketForm: {
id: 'zendesk.selectTicketForm',
description: 'Select ticket form',
defaultMessage: 'Please choose your request type:',
},
'registration.other.options.heading': {
id: 'registration.other.options.heading',
defaultMessage: 'Or register with:',

View File

@@ -1,8 +1,7 @@
/* eslint-disable import/no-import-module-exports */
/* eslint-disable react/function-component-definition */
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getSiteConfig } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import {
@@ -15,7 +14,7 @@ import EmbeddedRegistrationRoute from '../EmbeddedRegistrationRoute';
const RRD = require('react-router-dom');
// Just render plain div with its children
// eslint-disable-next-line react/prop-types
RRD.BrowserRouter = ({ children }) => <div>{ children }</div>;
RRD.BrowserRouter = ({ children }) => <div>{children}</div>;
module.exports = RRD;
const TestApp = () => (
@@ -60,7 +59,7 @@ describe('EmbeddedRegistrationRoute', () => {
it('should render embedded register page if host query param is available in the url (embedded)', async () => {
delete window.location;
window.location = {
href: getConfig().BASE_URL.concat(REGISTER_EMBEDDED_PAGE),
href: getSiteConfig().baseUrl.concat(REGISTER_EMBEDDED_PAGE),
search: '?host=http://localhost/host-websit',
};

View File

@@ -1,20 +1,10 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { IntlProvider } from '@openedx/frontend-base';
import { fireEvent, render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import { RegisterProvider } from '../../register/components/RegisterContext';
import { useFieldValidations } from '../../register/data/apiHook';
import FormGroup from '../FormGroup';
import PasswordField from '../PasswordField';
// Mock the useFieldValidations hook
jest.mock('../../register/data/apiHook', () => ({
useFieldValidations: jest.fn(),
}));
describe('FormGroup', () => {
const props = {
@@ -42,51 +32,24 @@ describe('FormGroup', () => {
describe('PasswordField', () => {
let props = {};
let queryClient;
let mockMutate;
const renderWrapper = (children) => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
<RegisterProvider>
{children}
</RegisterProvider>
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>
const wrapper = children => (
<IntlProvider locale="en">
{children}
</IntlProvider>
);
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockMutate = jest.fn();
useFieldValidations.mockReturnValue({
mutate: mockMutate,
isPending: false,
});
props = {
floatingLabel: 'Password',
name: 'password',
value: 'password123',
handleFocus: jest.fn(),
};
jest.clearAllMocks();
});
it('should show/hide password on icon click', () => {
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
const showPasswordButton = getByLabelText('Show password');
@@ -99,7 +62,7 @@ describe('PasswordField', () => {
});
it('should show password requirement tooltip on focus', async () => {
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
jest.useFakeTimers();
await act(async () => {
@@ -116,7 +79,7 @@ describe('PasswordField', () => {
...props,
value: '',
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
jest.useFakeTimers();
await act(async () => {
@@ -139,7 +102,7 @@ describe('PasswordField', () => {
});
it('should update password requirement checks', async () => {
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = getByLabelText('Password');
jest.useFakeTimers();
await act(async () => {
@@ -162,7 +125,7 @@ describe('PasswordField', () => {
});
it('should not run validations when blur is fired on password icon click', () => {
const { container, getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { container, getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
const passwordIcon = getByLabelText('Show password');
@@ -183,7 +146,7 @@ describe('PasswordField', () => {
...props,
handleBlur: jest.fn(),
};
const { container } = render(renderWrapper(<PasswordField {...props} />));
const { container } = render(wrapper(<PasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
fireEvent.blur(passwordInput, {
@@ -201,7 +164,7 @@ describe('PasswordField', () => {
...props,
handleErrorChange: jest.fn(),
};
const { container } = render(renderWrapper(<PasswordField {...props} />));
const { container } = render(wrapper(<PasswordField {...props} />));
const passwordInput = container.querySelector('input[name="password"]');
fireEvent.blur(passwordInput, {
@@ -224,7 +187,7 @@ describe('PasswordField', () => {
handleErrorChange: jest.fn(),
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');
@@ -244,7 +207,7 @@ describe('PasswordField', () => {
handleErrorChange: jest.fn(),
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');
@@ -263,11 +226,13 @@ describe('PasswordField', () => {
});
it('should run backend validations when frontend validations pass on blur when rendered from register page', () => {
const mockValidateField = jest.fn();
props = {
...props,
handleErrorChange: jest.fn(),
validateField: mockValidateField,
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordField = getByLabelText('Password');
fireEvent.blur(passwordField, {
target: {
@@ -276,7 +241,7 @@ describe('PasswordField', () => {
},
});
expect(mockMutate).toHaveBeenCalledWith({ password: 'password123' });
expect(mockValidateField).toHaveBeenCalledWith({ password: 'password123' });
});
it('should use password value from prop when password icon is focused out (blur due to icon)', () => {
@@ -286,7 +251,7 @@ describe('PasswordField', () => {
handleErrorChange: jest.fn(),
handleBlur: jest.fn(),
};
const { getByLabelText } = render(renderWrapper(<PasswordField {...props} />));
const { getByLabelText } = render(wrapper(<PasswordField {...props} />));
const passwordIcon = getByLabelText('Show password');

View File

@@ -1,4 +1,4 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@openedx/frontend-base';
import renderer from 'react-test-renderer';
import registerIcons from '../RegisterFaIcons';

View File

@@ -1,4 +1,4 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider } from '@openedx/frontend-base';
import renderer from 'react-test-renderer';
import { PENDING_STATE, REGISTER_PAGE } from '../../data/constants';

View File

@@ -1,6 +1,7 @@
/* eslint-disable import/no-import-module-exports */
/* eslint-disable react/function-component-definition */
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { fetchAuthenticatedUser, getAuthenticatedUser } from '@openedx/frontend-base';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import {
@@ -10,7 +11,8 @@ import {
import { UnAuthOnlyRoute } from '..';
import { REGISTER_PAGE } from '../../data/constants';
jest.mock('@edx/frontend-platform/auth', () => ({
jest.mock('@openedx/frontend-base', () => ({
...jest.requireActual('@openedx/frontend-base'),
getAuthenticatedUser: jest.fn(),
fetchAuthenticatedUser: jest.fn(),
}));

View File

@@ -1,17 +0,0 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import renderer from 'react-test-renderer';
import Zendesk from '../Zendesk';
jest.mock('react-zendesk', () => 'Zendesk');
describe('Zendesk Help', () => {
it('should match login page third party auth alert message snapshot', () => {
const tree = renderer.create(
<IntlProvider locale="en">
<Zendesk />
</IntlProvider>,
).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -13,7 +13,7 @@ exports[`ThirdPartyAuthAlert renders skeleton for pending third-party auth 1`] =
className="alert-message-content"
>
<p>
You have successfully signed into Google, but your Google account does not have a linked Your Platform Name Here account. To link your accounts, sign in now using your Your Platform Name Here password.
You have successfully signed into Google, but your Google account does not have a linked Test Site account. To link your accounts, sign in now using your Test Site password.
</p>
</div>
</div>
@@ -33,7 +33,7 @@ exports[`ThirdPartyAuthAlert should match login page third party auth alert mess
className="alert-message-content"
>
<p>
You have successfully signed into Google, but your Google account does not have a linked Your Platform Name Here account. To link your accounts, sign in now using your Your Platform Name Here password.
You have successfully signed into Google, but your Google account does not have a linked Test Site account. To link your accounts, sign in now using your Test Site password.
</p>
</div>
</div>
@@ -59,7 +59,7 @@ exports[`ThirdPartyAuthAlert should match register page third party auth alert m
Almost done!
</div>
<p>
You've successfully signed into Google! We just need a little more information before you start learning with Your Platform Name Here.
You've successfully signed into Google! We just need a little more information before you start learning with Test Site.
</p>
</div>
</div>

View File

@@ -1,65 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Zendesk Help should match login page third party auth alert message snapshot 1`] = `
<Zendesk
cookies={true}
defer={true}
webWidget={
{
"answerBot": {
"avatar": {
"name": {
"*": "edX Support",
},
"url": undefined,
},
"contactOnlyAfterQuery": true,
"suppress": false,
"title": {
"*": "edX Support",
},
},
"chat": {
"departments": {
"enabled": [
"account settings",
"billing and payments",
"certificates",
"deadlines",
"errors and technical issues",
"other",
"proctoring",
],
},
"suppress": false,
},
"contactForm": {
"attachments": true,
"selectTicketForm": {
"*": "Please choose your request type:",
},
"ticketForms": [
{
"fields": [
{
"id": "description",
"prefill": {
"*": "",
},
},
],
"id": 360003368814,
"subject": false,
},
],
},
"contactOptions": {
"enabled": false,
},
"helpCenter": {
"originalArticleButton": true,
},
}
}
/>
`;

View File

@@ -1,40 +0,0 @@
const configuration = {
// Cookies related configs
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN,
USER_RETENTION_COOKIE_NAME: process.env.USER_RETENTION_COOKIE_NAME || '',
// Features
DISABLE_ENTERPRISE_LOGIN: process.env.DISABLE_ENTERPRISE_LOGIN || '',
ENABLE_AUTO_GENERATED_USERNAME: process.env.ENABLE_AUTO_GENERATED_USERNAME || false,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false,
ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: process.env.ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN || false,
ENABLE_POST_REGISTRATION_RECOMMENDATIONS: process.env.ENABLE_POST_REGISTRATION_RECOMMENDATIONS || false,
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false,
SHOW_REGISTRATION_LINKS: process.env.SHOW_REGISTRATION_LINKS !== 'false',
ENABLE_IMAGE_LAYOUT: process.env.ENABLE_IMAGE_LAYOUT || false,
// Links
ACTIVATION_EMAIL_SUPPORT_LINK: process.env.ACTIVATION_EMAIL_SUPPORT_LINK || null,
AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: process.env.AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK || null,
LOGIN_ISSUE_SUPPORT_LINK: process.env.LOGIN_ISSUE_SUPPORT_LINK || null,
PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK || null,
POST_REGISTRATION_REDIRECT_URL: process.env.POST_REGISTRATION_REDIRECT_URL || '',
PRIVACY_POLICY: process.env.PRIVACY_POLICY || null,
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
TOS_AND_HONOR_CODE: process.env.TOS_AND_HONOR_CODE || null,
TOS_LINK: process.env.TOS_LINK || null,
// Base container images
BANNER_IMAGE_LARGE: process.env.BANNER_IMAGE_LARGE || '',
BANNER_IMAGE_MEDIUM: process.env.BANNER_IMAGE_MEDIUM || '',
BANNER_IMAGE_SMALL: process.env.BANNER_IMAGE_SMALL || '',
BANNER_IMAGE_EXTRA_SMALL: process.env.BANNER_IMAGE_EXTRA_SMALL || '',
// Recommendation constants
GENERAL_RECOMMENDATIONS: process.env.GENERAL_RECOMMENDATIONS || '[]',
// Miscellaneous
INFO_EMAIL: process.env.INFO_EMAIL || '',
ZENDESK_KEY: process.env.ZENDESK_KEY,
ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL,
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '',
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '',
};
export default configuration;

View File

@@ -1,20 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import algoliasearch from 'algoliasearch';
// initialize Algolia workers
const initializeSearchClient = () => algoliasearch(
getConfig().ALGOLIA_APP_ID,
getConfig().ALGOLIA_SEARCH_API_KEY,
);
const getLocationRestrictionFilter = (userCountry) => {
if (userCountry) {
return `NOT blocked_in:"${userCountry}" AND (allowed_in:"null" OR allowed_in:"${userCountry}")`;
}
return '';
};
export {
initializeSearchClient,
getLocationRestrictionFilter,
};

View File

@@ -5,7 +5,6 @@ export const REGISTER_EMBEDDED_PAGE = '/register-embedded';
export const RESET_PAGE = '/reset';
export const AUTHN_PROGRESSIVE_PROFILING = '/welcome';
export const DEFAULT_REDIRECT_URL = '/dashboard';
export const RECOMMENDATIONS = '/recommendations';
export const PASSWORD_RESET_CONFIRM = '/password_reset_confirm/:token/';
export const PAGE_NOT_FOUND = '/notfound';
export const ENTERPRISE_LOGIN_URL = '/enterprise/login';
@@ -29,9 +28,9 @@ export const EMBEDDED = 'embedded';
export const LETTER_REGEX = /[a-zA-Z]/;
export const NUMBER_REGEX = /\d/;
export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*'
+ '|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"'
+ ')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+)(?:[A-Z0-9-]{2,63})'
+ '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$';
+ '|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"'
+ ')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+)(?:[A-Z0-9-]{2,63})'
+ '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$';
// Query string parameters that can be passed to LMS to manage
// things like auto-enrollment upon login and registration.

59
src/data/countries.ts Normal file
View File

@@ -0,0 +1,59 @@
import { getPrimaryLanguageSubtag } from '@openedx/frontend-base';
import COUNTRIES, { langs as countryLangs } from 'i18n-iso-countries';
import arLocale from 'i18n-iso-countries/langs/ar.json';
import caLocale from 'i18n-iso-countries/langs/ca.json';
import enLocale from 'i18n-iso-countries/langs/en.json';
import esLocale from 'i18n-iso-countries/langs/es.json';
import frLocale from 'i18n-iso-countries/langs/fr.json';
import heLocale from 'i18n-iso-countries/langs/he.json';
import idLocale from 'i18n-iso-countries/langs/id.json';
import koLocale from 'i18n-iso-countries/langs/ko.json';
import plLocale from 'i18n-iso-countries/langs/pl.json';
import ptLocale from 'i18n-iso-countries/langs/pt.json';
import ruLocale from 'i18n-iso-countries/langs/ru.json';
import ukLocale from 'i18n-iso-countries/langs/uk.json';
import zhLocale from 'i18n-iso-countries/langs/zh.json';
COUNTRIES.registerLocale(arLocale);
COUNTRIES.registerLocale(enLocale);
COUNTRIES.registerLocale(esLocale);
COUNTRIES.registerLocale(frLocale);
COUNTRIES.registerLocale(zhLocale);
COUNTRIES.registerLocale(caLocale);
COUNTRIES.registerLocale(heLocale);
COUNTRIES.registerLocale(idLocale);
COUNTRIES.registerLocale(koLocale);
COUNTRIES.registerLocale(plLocale);
COUNTRIES.registerLocale(ptLocale);
COUNTRIES.registerLocale(ruLocale);
COUNTRIES.registerLocale(ukLocale);
/**
* Provides a lookup table of country IDs to country names for the current locale.
*
* @memberof module:I18n
*/
export function getCountryMessages(locale) {
const primaryLanguageSubtag = getPrimaryLanguageSubtag(locale);
const languageCode = countryLangs().includes(primaryLanguageSubtag) ? primaryLanguageSubtag : 'en';
return COUNTRIES.getNames(languageCode);
}
/**
* Provides a list of countries represented as objects of the following shape:
*
* {
* key, // The ID of the country
* name // The localized name of the country
* }
*
* TODO: ARCH-878: The list should be sorted alphabetically in the current locale.
* This is useful for populating dropdowns.
*
* @memberof module:I18n
*/
export function getCountryList(locale) {
const countryMessages = getCountryMessages(locale);
return Object.entries(countryMessages).map(([code, name]) => ({ code, name }));
}

View File

@@ -1,17 +0,0 @@
import {
createInstance,
} from '@optimizely/react-sdk';
const OPTIMIZELY_SDK_KEY = process.env.OPTIMIZELY_FULL_STACK_SDK_KEY;
const getOptimizelyInstance = () => {
if (OPTIMIZELY_SDK_KEY) {
return createInstance({
sdkKey: OPTIMIZELY_SDK_KEY,
});
}
return null;
};
export default getOptimizelyInstance();

View File

@@ -1,16 +0,0 @@
import { getLocationRestrictionFilter } from '../algolia';
describe('algoliaUtilsTests', () => {
it('test getLocationRestrictionFilter returns filter if country is passed', () => {
const countryCode = 'PK';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = `NOT blocked_in:"${countryCode}" AND (allowed_in:"null" OR allowed_in:"${countryCode}")`;
expect(filter).toEqual(expectedFilter);
});
it('test getLocationRestrictionFilter returns empty string if country is not passed', () => {
const countryCode = '';
const filter = getLocationRestrictionFilter(countryCode);
const expectedFilter = '';
expect(filter).toEqual(expectedFilter);
});
});

View File

@@ -1,13 +1,7 @@
import { getConfig } from '@edx/frontend-platform';
import Cookies from 'universal-cookie';
import { setCookie } from '../utils';
// Mock getConfig function
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
// Mock Cookies class
jest.mock('universal-cookie');
@@ -17,9 +11,7 @@ describe('setCookie function', () => {
});
it('should set a cookie with default options', () => {
getConfig.mockReturnValue({ SESSION_COOKIE_DOMAIN: 'example.com' });
setCookie('testCookie', 'testValue');
setCookie('testCookie', 'testValue', 'example.com');
expect(Cookies).toHaveBeenCalled();
expect(Cookies).toHaveBeenCalledWith();
@@ -30,10 +22,8 @@ describe('setCookie function', () => {
});
it('should set a cookie with specified expiry', () => {
getConfig.mockReturnValue({ SESSION_COOKIE_DOMAIN: 'example.com' });
const expiry = new Date('2023-12-31');
setCookie('testCookie', 'testValue', expiry);
setCookie('testCookie', 'testValue', 'example.com', expiry);
expect(Cookies).toHaveBeenCalled();
expect(Cookies).toHaveBeenCalledWith();
@@ -45,7 +35,7 @@ describe('setCookie function', () => {
});
it('should not set a cookie if cookieName is undefined', () => {
setCookie(undefined, 'testValue');
setCookie(undefined, 'testValue', 'example.com');
expect(Cookies).not.toHaveBeenCalled();
});

View File

@@ -1,10 +1,9 @@
import { getConfig } from '@edx/frontend-platform';
import Cookies from 'universal-cookie';
export default function setCookie(cookieName, cookieValue, cookieExpiry) {
export default function setCookie(cookieName, cookieValue, cookieDomain, cookieExpiry) {
if (cookieName) { // To avoid setting getting exception when setting cookie with undefined names.
const cookies = new Cookies();
const options = { domain: getConfig().SESSION_COOKIE_DOMAIN, path: '/' };
const options = { domain: cookieDomain, path: '/' };
if (cookieExpiry) {
options.expires = cookieExpiry;
}

View File

@@ -40,10 +40,8 @@ export const updatePathWithQueryParams = (path) => {
return path;
}
if (queryParams.indexOf('track=pwreset') > -1) {
queryParams = queryParams.replace(
'?track=pwreset&', '?',
).replace('?track=pwreset', '').replace('&track=pwreset', '').replace('?&', '?');
if (queryParams.includes('track=pwreset')) {
queryParams = queryParams.replace('?track=pwreset&', '?',).replace('?track=pwreset', '').replace('&track=pwreset', '').replace('?&', '?');
}
return `${path}${queryParams}`;
@@ -53,7 +51,7 @@ export const getAllPossibleQueryParams = (locationURl = null) => {
const urlParams = locationURl ? QueryString.parseUrl(locationURl).query : QueryString.parse(window.location.search);
const params = {};
Object.entries(urlParams).forEach(([key, value]) => {
if (AUTH_PARAMS.indexOf(key) > -1) {
if (AUTH_PARAMS.includes(key)) {
params[key] = value;
}
});

View File

@@ -10,7 +10,7 @@ import { breakpoints } from '@openedx/paragon';
const useMobileResponsive = (breakpoint) => {
const [isMobileWindow, setIsMobileWindow] = useState();
const checkForMobile = () => {
setIsMobileWindow(window.matchMedia(`(max-width: ${breakpoint || breakpoints.small.maxWidth}px)`).matches);
setIsMobileWindow(window.matchMedia(`(max-width: ${breakpoint ?? breakpoints.small.maxWidth}px)`).matches);
};
useEffect(() => {
checkForMobile();

View File

@@ -1,4 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { getSiteConfig } from '@openedx/frontend-base';
import { fireEvent, render } from '@testing-library/react';
import FieldRenderer from '../FieldRenderer';
@@ -43,7 +43,7 @@ describe('FieldRendererTests', () => {
name: 'yob-field',
};
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => { }} />);
expect(container.innerHTML).toEqual('');
});
@@ -84,7 +84,7 @@ describe('FieldRendererTests', () => {
it('should render checkbox field', () => {
const fieldData = {
type: 'checkbox',
label: `I agree that ${getConfig().SITE_NAME} may send me marketing messages.`,
label: `I agree that ${getSiteConfig().siteName} may send me marketing messages.`,
name: 'marketing-emails-opt-in-field',
};
@@ -103,7 +103,7 @@ describe('FieldRendererTests', () => {
type: 'unknown',
};
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
const { container } = render(<FieldRenderer fieldData={fieldData} onChangeHandler={() => { }} />);
expect(container.innerHTML).toContain('');
});

View File

@@ -1,14 +1,13 @@
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useAppConfig, useIntl } from '@openedx/frontend-base';
import { Alert } from '@openedx/paragon';
import { CheckCircle, Error } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import {
COMPLETE_STATE, FORBIDDEN_STATE, FORM_SUBMISSION_ERROR, INTERNAL_SERVER_ERROR,
} from '../data/constants';
import { PASSWORD_RESET } from '../reset-password/data/constants';
import messages from './messages';
const ForgotPasswordAlert = (props) => {
const { formatMessage } = useIntl();
@@ -34,7 +33,7 @@ const ForgotPasswordAlert = (props) => {
values={{
email: <span className="data-hj-suppress">{email}</span>,
supportLink: (
<Alert.Link href={getConfig().PASSWORD_RESET_SUPPORT_LINK} target="_blank">
<Alert.Link href={useAppConfig().PASSWORD_RESET_SUPPORT_LINK} target="_blank">
{formatMessage(messages['confirmation.support.link'])}
</Alert.Link>
),

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
getSiteConfig, sendPageEvent, sendTrackEvent, useAppConfig, useIntl,
} from '@openedx/frontend-base';
import {
Form,
Hyperlink,
@@ -24,11 +24,12 @@ import { LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants';
import { updatePathWithQueryParams, windowScrollTo } from '../data/utils';
const ForgotPasswordPage = () => {
const platformName = getConfig().SITE_NAME;
const platformName = getSiteConfig().siteName;
const emailRegex = new RegExp(VALID_EMAIL_REGEX, 'i');
const { formatMessage } = useIntl();
const navigate = useNavigate();
const location = useLocation();
const appConfig = useAppConfig();
const [email, setEmail] = useState('');
const [bannerEmail, setBannerEmail] = useState('');
const [formErrors, setFormErrors] = useState('');
@@ -110,7 +111,7 @@ const ForgotPasswordPage = () => {
<BaseContainer>
<Helmet>
<title>{formatMessage(messages['forgot.password.page.title'],
{ siteName: getConfig().SITE_NAME })}
{ siteName: getSiteConfig().siteName })}
</title>
</Helmet>
<div>
@@ -151,12 +152,12 @@ const ForgotPasswordPage = () => {
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
/>
{(getConfig().LOGIN_ISSUE_SUPPORT_LINK) && (
{(appConfig.LOGIN_ISSUE_SUPPORT_LINK) && (
<Hyperlink
id="forgot-password"
name="forgot-password"
className="ml-4 font-weight-500 text-body"
destination={getConfig().LOGIN_ISSUE_SUPPORT_LINK}
destination={appConfig.LOGIN_ISSUE_SUPPORT_LINK}
target="_blank"
showLaunchIcon={false}
>
@@ -166,7 +167,7 @@ const ForgotPasswordPage = () => {
<p className="mt-5.5 small text-gray-700">
{formatMessage(messages['additional.help.text'], { platformName })}
<span className="mx-1">
<Hyperlink isInline destination={`mailto:${getConfig().INFO_EMAIL}`}>{getConfig().INFO_EMAIL}</Hyperlink>
<Hyperlink isInline destination={`mailto:${appConfig.INFO_EMAIL}`}>{appConfig.INFO_EMAIL}</Hyperlink>
</span>
</p>
</Form>

View File

@@ -1,21 +1,17 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getSiteConfig } from '@openedx/frontend-base';
import formurlencoded from 'form-urlencoded';
import { forgotPassword } from './api';
// Mock the platform dependencies
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
jest.mock('@openedx/frontend-base', () => ({
getSiteConfig: jest.fn(),
getAuthenticatedHttpClient: jest.fn(),
}));
jest.mock('form-urlencoded', () => jest.fn());
const mockGetConfig = getConfig as jest.MockedFunction<typeof getConfig>;
const mockGetSiteConfig = getSiteConfig as jest.MockedFunction<typeof getSiteConfig>;
const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as
jest.MockedFunction<typeof getAuthenticatedHttpClient>;
const mockFormurlencoded = formurlencoded as jest.MockedFunction<typeof formurlencoded>;
@@ -26,19 +22,19 @@ describe('forgot-password api', () => {
};
const mockConfig = {
LMS_BASE_URL: 'http://localhost:18000',
};
lmsBaseUrl: 'http://localhost:18000',
} as ReturnType<typeof getSiteConfig>;
beforeEach(() => {
jest.clearAllMocks();
mockGetConfig.mockReturnValue(mockConfig);
mockGetSiteConfig.mockReturnValue(mockConfig);
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
mockFormurlencoded.mockImplementation((data) => `encoded=${JSON.stringify(data)}`);
});
describe('forgotPassword', () => {
const testEmail = 'test@example.com';
const expectedUrl = `${mockConfig.LMS_BASE_URL}/account/password`;
const expectedUrl = `${mockConfig.lmsBaseUrl}/account/password`;
const expectedConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
@@ -60,7 +56,7 @@ describe('forgot-password api', () => {
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`encoded=${JSON.stringify({ email: testEmail })}`,
expectedConfig
expectedConfig,
);
expect(result).toEqual(mockResponse.data);
});
@@ -71,7 +67,7 @@ describe('forgot-password api', () => {
data: {
message: 'Email is required',
success: false,
}
},
};
mockHttpClient.post.mockResolvedValueOnce(mockResponse);
@@ -96,7 +92,7 @@ describe('forgot-password api', () => {
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
expect.any(String),
expectedConfig
expectedConfig,
);
});
@@ -110,7 +106,6 @@ describe('forgot-password api', () => {
it('should handle response with no data field', async () => {
const mockResponse = {
// No data field
status: 200,
statusText: 'OK',
};

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getAuthenticatedHttpClient, getSiteConfig } from '@openedx/frontend-base';
import formurlencoded from 'form-urlencoded';
const forgotPassword = async (email: string) => {
@@ -10,7 +9,7 @@ const forgotPassword = async (email: string) => {
const { data } = await getAuthenticatedHttpClient()
.post(
`${getConfig().LMS_BASE_URL}/account/password`,
`${getSiteConfig().lmsBaseUrl}/account/password`,
formurlencoded({ email }),
requestConfig,
)

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { logError, logInfo } from '@openedx/frontend-base';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
@@ -8,7 +8,7 @@ import * as api from './api';
import { useForgotPassword } from './apiHook';
// Mock the logging functions
jest.mock('@edx/frontend-platform/logging', () => ({
jest.mock('@openedx/frontend-base', () => ({
logError: jest.fn(),
logInfo: jest.fn(),
}));

View File

@@ -1,23 +1,23 @@
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { logError, logInfo } from '@openedx/frontend-base';
import { useMutation } from '@tanstack/react-query';
import { forgotPassword } from './api';
interface ForgotPasswordResult {
success: boolean;
message?: string;
success: boolean,
message?: string,
}
interface UseForgotPasswordOptions {
onSuccess?: (data: ForgotPasswordResult, email: string) => void;
onError?: (error: Error) => void;
onSuccess?: (data: ForgotPasswordResult, email: string) => void,
onError?: (error: Error) => void,
}
interface ApiError {
interface ApiError extends Error {
response?: {
status: number;
data: Record<string, unknown>;
};
status: number,
data: Record<string, unknown>,
},
}
const useForgotPassword = (options: UseForgotPasswordOptions = {}) => useMutation({
@@ -31,13 +31,13 @@ const useForgotPassword = (options: UseForgotPasswordOptions = {}) => useMutatio
},
onError: (error: ApiError) => {
// Handle different error types like the saga did
if (error.response && error.response.status === 403) {
if (error.response?.status === 403) {
logInfo(error);
} else {
logError(error);
}
if (options.onError) {
options.onError(error as Error);
options.onError(error);
}
},
});

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@openedx/frontend-base';
const messages = defineMessages({
'forgot.password.page.title': {

View File

@@ -1,11 +1,13 @@
import { mergeConfig } from '@edx/frontend-platform';
import { configure, IntlProvider } from '@edx/frontend-platform/i18n';
import {
CurrentAppProvider, IntlProvider, mergeAppConfig,
} from '@openedx/frontend-base';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { appId } from '../../constants';
import {
FORBIDDEN_STATE, FORM_SUBMISSION_ERROR, INTERNAL_SERVER_ERROR, LOGIN_PAGE,
} from '../../data/constants';
@@ -16,11 +18,15 @@ import ForgotPasswordPage from '../ForgotPasswordPage';
const mockedNavigator = jest.fn();
jest.mock('@edx/frontend-platform/analytics', () => ({
jest.mock('@openedx/frontend-base', () => ({
...jest.requireActual('@openedx/frontend-base'),
sendPageEvent: jest.fn(),
sendTrackEvent: jest.fn(),
getAuthenticatedUser: jest.fn(() => ({
userId: 3,
username: 'test-user',
})),
}));
jest.mock('@edx/frontend-platform/auth');
jest.mock('react-router-dom', () => ({
...(jest.requireActual('react-router-dom')),
useNavigate: () => mockedNavigator,
@@ -31,7 +37,7 @@ jest.mock('../data/apiHook', () => ({
}));
describe('ForgotPasswordPage', () => {
mergeConfig({
mergeAppConfig(appId, {
LOGIN_ISSUE_SUPPORT_LINK: '',
INFO_EMAIL: '',
});
@@ -65,7 +71,9 @@ describe('ForgotPasswordPage', () => {
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
{component}
<CurrentAppProvider appId={appId}>
{component}
</CurrentAppProvider>
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>
@@ -85,21 +93,6 @@ describe('ForgotPasswordPage', () => {
},
});
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({
userId: 3,
username: 'test-user',
})),
}));
configure({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages: { 'es-419': {}, de: {}, 'en-us': {} },
});
// Clear mock calls between tests
jest.clearAllMocks();
});
@@ -111,7 +104,7 @@ describe('ForgotPasswordPage', () => {
});
it('should display need other help signing in button', () => {
mergeConfig({
mergeAppConfig(appId, {
LOGIN_ISSUE_SUPPORT_LINK: '/support',
});
render(renderWrapper(<ForgotPasswordPage />));
@@ -354,7 +347,9 @@ describe('ForgotPasswordAlert', () => {
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter>
<ForgotPasswordAlert {...props} />
<CurrentAppProvider appId={appId}>
<ForgotPasswordAlert {...props} />
</CurrentAppProvider>
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>,

View File

@@ -1 +1,25 @@
export default [];
// Placeholder be overridden by `make pull_translations`
export default {
ar: {},
'zh-hk': {},
'zh-cn': {},
uk: {},
'tr-tr': {},
th: {},
te: {},
ru: {},
'pt-pt': {},
'pt-br': {},
'it-it': {},
id: {},
hi: {},
he: {},
'fr-ca': {},
fa: {},
'es-es': {},
'es-419': {},
el: {},
'de-de': {},
da: {},
bo: {},
};

View File

@@ -1,43 +0,0 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import { StrictMode } from 'react';
import {
APP_INIT_ERROR, APP_READY, initialize, mergeConfig, subscribe,
} from '@edx/frontend-platform';
import { ErrorPage } from '@edx/frontend-platform/react';
import { createRoot } from 'react-dom/client';
import configuration from './config';
import messages from './i18n';
import MainApp from './MainApp';
subscribe(APP_READY, () => {
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<MainApp />
</StrictMode>,
);
});
subscribe(APP_INIT_ERROR, (error) => {
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<ErrorPage message={error.message} />
</StrictMode>,
);
});
initialize({
handlers: {
config: () => {
mergeConfig(configuration);
},
},
messages,
});

View File

@@ -1,75 +0,0 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "sass/style";
//commit color - Andal Learning Brand Colors - Override Paragon variables
:root {
--pgn-color-primary: #ff4f00;
--pgn-color-primary-100: #ffe6cc;
--pgn-color-primary-200: #ffcc99;
--pgn-color-primary-300: #ffb366;
--pgn-color-primary-400: #ff9933;
--pgn-color-primary-500: #ff4f00;
--pgn-color-primary-600: #cc3f00;
--pgn-color-primary-700: #992f00;
--pgn-color-primary-800: #661f00;
--pgn-color-primary-900: #330f00;
--pgn-color-action-primary: #ff4f00;
--pgn-color-action-primary-hover: #cc3f00;
--pgn-color-action-primary-focus: #992f00;
--pgn-color-action-primary-active: #992f00;
--pgn-color-primary-base: #ff4f00;
}
// Override all button variants to use Andal orange
.btn-primary {
background-color: #ff4f00 !important;
border-color: #ff4f00 !important;
&:hover {
background-color: #cc3f00 !important;
border-color: #cc3f00 !important;
}
}
.btn-outline-primary {
color: #ff4f00 !important;
border-color: #ff4f00 !important;
&:hover {
background-color: #ff4f00 !important;
color: #fff !important;
}
}
.btn-brand {
background-color: #ff4f00 !important;
border-color: #ff4f00 !important;
color: #fff !important;
}
// Override link colors
a {
color: #ff4f00;
&:hover {
color: #cc3f00;
}
}
// Override active states
.active {
background-color: #ff4f00 !important;
color: #fff !important;
}
// Hide Register tab, only show Sign in
.nav-tabs {
.nav-link[href*="register"] {
display: none !important;
}
}
// Change "with Andal LND" text color from blue to black
.text-accent-a {
color: #000000 !important;
}

3
src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default as authnApp } from './app';
export { default as authnRoutes } from './routes';
export { default as authnMessages } from './i18n';

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useAppConfig, useIntl } from '@openedx/frontend-base';
import { Alert } from '@openedx/paragon';
import { CheckCircle, Error } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
@@ -15,7 +14,7 @@ const AccountActivationMessage = ({ messageType }) => {
}
const variant = messageType === ACCOUNT_ACTIVATION_MESSAGE.ERROR ? 'danger' : messageType;
const activationOrConfirmation = getConfig().MARKETING_EMAILS_OPT_IN ? 'confirmation' : 'activation';
const activationOrConfirmation = useAppConfig().MARKETING_EMAILS_OPT_IN ? 'confirmation' : 'activation';
const iconMapping = {
[ACCOUNT_ACTIVATION_MESSAGE.SUCCESS]: CheckCircle,
[ACCOUNT_ACTIVATION_MESSAGE.ERROR]: Error,
@@ -35,7 +34,7 @@ const AccountActivationMessage = ({ messageType }) => {
}
case ACCOUNT_ACTIVATION_MESSAGE.ERROR: {
const supportLink = (
<Alert.Link href={getConfig().ACTIVATION_EMAIL_SUPPORT_LINK}>
<Alert.Link href={useAppConfig().ACTIVATION_EMAIL_SUPPORT_LINK}>
{formatMessage(messages['account.activation.support.link'])}
</Alert.Link>
);

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
import {
ActionRow, ModalDialog, useToggle,
} from '@openedx/paragon';
@@ -9,10 +8,10 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import { Link, useNavigate } from 'react-router-dom';
import messages from './messages';
import { DEFAULT_REDIRECT_URL, RESET_PAGE } from '../data/constants';
import { updatePathWithQueryParams } from '../data/utils';
import useMobileResponsive from '../data/utils/useMobileResponsive';
import messages from './messages';
const ChangePasswordPrompt = ({ variant, redirectUrl }) => {
const isMobileView = useMobileResponsive();
@@ -22,11 +21,11 @@ const ChangePasswordPrompt = ({ variant, redirectUrl }) => {
if (variant === 'block') {
setRedirectToResetPasswordPage(true);
} else {
window.location.href = redirectUrl || getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
window.location.href = redirectUrl || getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL);
}
},
};
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isOpen, open, close] = useToggle(true, handlers);
const { formatMessage } = useIntl();
const navigate = useNavigate();

View File

@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getAuthService } from '@edx/frontend-platform/auth';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
FormattedMessage, getAuthService, getSiteConfig, useIntl
} from '@openedx/frontend-base';
import { Alert, Hyperlink } from '@openedx/paragon';
import { Error } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import { windowScrollTo } from '../data/utils';
import ChangePasswordPrompt from './ChangePasswordPrompt';
import {
ACCOUNT_LOCKED_OUT,
@@ -23,7 +24,6 @@ import {
TPA_AUTHENTICATION_FAILURE,
} from './data/constants';
import messages from './messages';
import { windowScrollTo } from '../data/utils';
const LoginFailureMessage = (props) => {
const { formatMessage } = useIntl();
@@ -75,6 +75,7 @@ const LoginFailureMessage = (props) => {
defaultMessage="In order to sign in, you need to activate your account.{lineBreak}
{lineBreak}We just sent an activation link to {email}. If you do not receive an email,
check your spam folders or {supportLink}."
description="An error message shown to users when they sign in if they have not yet activated their account. It attempts to explain to them how to activate their account by looking for an email from the system."
values={{
lineBreak: <br />,
email: <strong className="data-hj-suppress">{context.email}</strong>,
@@ -86,7 +87,7 @@ const LoginFailureMessage = (props) => {
break;
}
case ALLOWED_DOMAIN_LOGIN_ERROR: {
const url = `${getConfig().LMS_BASE_URL}/dashboard/?tpa_hint=${context.tpaHint}`;
const url = `${getSiteConfig().lmsBaseUrl}/dashboard/?tpa_hint=${context.tpaHint}`;
const tpaLink = (
<a href={url}>
{formatMessage(messages['tpa.account.link'], { provider: context.provider })}
@@ -161,6 +162,7 @@ const LoginFailureMessage = (props) => {
<FormattedMessage
id="login.incorrect.credentials.error.with.reset.link"
defaultMessage="The username, email, or password you entered is incorrect. Please try again or {resetLink}."
description="An error message shown to users if some part of their login information was incorrect."
values={{ resetLink }}
/>
</p>
@@ -184,7 +186,7 @@ const LoginFailureMessage = (props) => {
errorMessage = (
<p>
{formatMessage(messages['login.tpa.authentication.failure'], {
platform_name: getConfig().SITE_NAME,
platform_name: getSiteConfig().siteName,
lineBreak: <br />,
errorMessage: context.errorMessage,
})}
@@ -200,7 +202,7 @@ const LoginFailureMessage = (props) => {
return (
<Alert id="login-failure-alert" className="mb-5" variant="danger" icon={Error}>
<Alert.Heading>{formatMessage(messages['login.failure.header.title'])}</Alert.Heading>
{ errorMessage }
{errorMessage}
</Alert>
);
};

View File

@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
getSiteConfig, sendPageEvent, sendTrackEvent, useIntl
} from '@openedx/frontend-base';
import { Form, StatefulButton } from '@openedx/paragon';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
@@ -100,11 +100,11 @@ const LoginPage = ({
useEffect(() => {
sendPageEvent('login_and_registration', 'login');
}, []);
// Fetch third-party auth context data
useEffect(() => {
setThirdPartyAuthContextBegin();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Sync third-party auth context data
useEffect(() => {
if (isSuccess && data) {
setThirdPartyAuthContextSuccess(
data.fieldDescriptions,
@@ -116,7 +116,7 @@ const LoginPage = ({
setThirdPartyAuthContextFailure();
}
}, [tpaHint, queryParams, isSuccess, data, error,
setThirdPartyAuthContextBegin, setThirdPartyAuthContextSuccess, setThirdPartyAuthContextFailure]);
setThirdPartyAuthContextSuccess, setThirdPartyAuthContextFailure]);
useEffect(() => {
if (thirdPartyErrorMessage) {
@@ -210,7 +210,7 @@ const LoginPage = ({
}
if (skipHintedLogin) {
window.location.href = getConfig().LMS_BASE_URL + provider.loginUrl;
window.location.href = getSiteConfig().lmsBaseUrl + provider.loginUrl;
return null;
}
@@ -227,11 +227,10 @@ const LoginPage = ({
/>
);
}
return (
<>
<Helmet>
<title>{formatMessage(messages['login.page.title'], { siteName: getConfig().SITE_NAME })}</title>
<title>{formatMessage(messages['login.page.title'], { siteName: getSiteConfig().siteName })}</title>
</Helmet>
<RedirectLogistration
success={loginResult.success}

View File

@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { LoginProvider, useLoginContext } from './LoginContext';
const TestComponent = () => {
@@ -29,7 +28,7 @@ describe('LoginContext', () => {
</LoginProvider>,
);
expect(screen.getByText('Test Child')).toBeInTheDocument();
expect(screen.getByText('Test Child')).toBeTruthy();
});
it('should provide all context values to children', () => {
@@ -39,12 +38,12 @@ describe('LoginContext', () => {
</LoginProvider>,
);
expect(screen.getByText('FormFields Available')).toBeInTheDocument();
expect(screen.getByText('EmailOrUsername Field Available')).toBeInTheDocument();
expect(screen.getByText('Password Field Available')).toBeInTheDocument();
expect(screen.getByText('Errors Available')).toBeInTheDocument();
expect(screen.getByText('EmailOrUsername Error Available')).toBeInTheDocument();
expect(screen.getByText('Password Error Available')).toBeInTheDocument();
expect(screen.getByText('FormFields Available')).toBeTruthy();
expect(screen.getByText('EmailOrUsername Field Available')).toBeTruthy();
expect(screen.getByText('Password Field Available')).toBeTruthy();
expect(screen.getByText('Errors Available')).toBeTruthy();
expect(screen.getByText('EmailOrUsername Error Available')).toBeTruthy();
expect(screen.getByText('Password Error Available')).toBeTruthy();
});
it('should render multiple children', () => {
@@ -56,8 +55,8 @@ describe('LoginContext', () => {
</LoginProvider>,
);
expect(screen.getByText('First Child')).toBeInTheDocument();
expect(screen.getByText('Second Child')).toBeInTheDocument();
expect(screen.getByText('Third Child')).toBeInTheDocument();
expect(screen.getByText('First Child')).toBeTruthy();
expect(screen.getByText('Second Child')).toBeTruthy();
expect(screen.getByText('Third Child')).toBeTruthy();
});
});

View File

@@ -1,28 +1,28 @@
import {
createContext, FC, ReactNode, useContext, useMemo, useState,
createContext, Dispatch, FC, ReactNode, SetStateAction, useContext, useMemo, useState,
} from 'react';
export interface FormFields {
emailOrUsername: string;
password: string;
emailOrUsername: string,
password: string,
}
export interface FormErrors {
emailOrUsername: string;
password: string;
emailOrUsername: string,
password: string,
}
interface LoginContextType {
formFields: FormFields;
setFormFields: (fields: FormFields) => void;
errors: FormErrors;
setErrors: (errors: FormErrors) => void;
formFields: FormFields,
setFormFields: Dispatch<SetStateAction<FormFields>>,
errors: FormErrors,
setErrors: Dispatch<SetStateAction<FormErrors>>,
}
const LoginContext = createContext<LoginContextType | undefined>(undefined);
interface LoginProviderProps {
children: ReactNode;
children: ReactNode,
}
export const LoginProvider: FC<LoginProviderProps> = ({ children }) => {

View File

@@ -1,27 +1,25 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCaseObject, getAuthenticatedHttpClient, getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base';
import * as QueryString from 'query-string';
import { login } from './api';
// Mock the platform dependencies
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
camelCaseObject: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
jest.mock('@openedx/frontend-base', () => ({
getSiteConfig: jest.fn(),
getAuthenticatedHttpClient: jest.fn(),
getUrlByRouteRole: jest.fn(),
camelCaseObject: jest.fn(),
}));
jest.mock('query-string', () => ({
stringify: jest.fn(),
}));
const mockGetConfig = getConfig as jest.MockedFunction<typeof getConfig>;
const mockGetSiteConfig = getSiteConfig as jest.MockedFunction<typeof getSiteConfig>;
const mockCamelCaseObject = camelCaseObject as jest.MockedFunction<typeof camelCaseObject>;
const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as
jest.MockedFunction<typeof getAuthenticatedHttpClient>;
const mockGetUrlByRouteRole = getUrlByRouteRole as jest.MockedFunction<typeof getUrlByRouteRole>;
const mockQueryStringify = QueryString.stringify as jest.MockedFunction<typeof QueryString.stringify>;
describe('login api', () => {
@@ -30,13 +28,14 @@ describe('login api', () => {
};
const mockConfig = {
LMS_BASE_URL: 'http://localhost:18000',
};
lmsBaseUrl: 'http://localhost:18000',
} as ReturnType<typeof getSiteConfig>;
beforeEach(() => {
jest.clearAllMocks();
mockGetConfig.mockReturnValue(mockConfig);
mockGetSiteConfig.mockReturnValue(mockConfig);
mockGetAuthenticatedHttpClient.mockReturnValue(mockHttpClient as any);
mockGetUrlByRouteRole.mockReturnValue('/dashboard');
mockCamelCaseObject.mockImplementation((obj) => obj);
mockQueryStringify.mockImplementation((obj) => `stringified=${JSON.stringify(obj)}`);
});
@@ -46,7 +45,7 @@ describe('login api', () => {
email_or_username: 'testuser@example.com',
password: 'password123',
};
const expectedUrl = `${mockConfig.LMS_BASE_URL}/api/user/v2/account/login_session/`;
const expectedUrl = `${mockConfig.lmsBaseUrl}/api/user/v2/account/login_session/`;
const expectedConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
@@ -151,7 +150,7 @@ describe('login api', () => {
expect(mockHttpClient.post).toHaveBeenCalledWith(
expectedUrl,
`stringified=${JSON.stringify(mockCredentials)}`,
expectedConfig
expectedConfig,
);
});

View File

@@ -1,5 +1,4 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCaseObject, getAuthenticatedHttpClient, getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base';
import * as QueryString from 'query-string';
const login = async (creds) => {
@@ -7,11 +6,12 @@ const login = async (creds) => {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
isPublic: true,
};
const url = `${getConfig().LMS_BASE_URL}/api/user/v2/account/login_session/`;
const url = `${getSiteConfig().lmsBaseUrl}/api/user/v2/account/login_session/`;
const { data } = await getAuthenticatedHttpClient()
.post(url, QueryString.stringify(creds), requestConfig);
const defaultRedirectUrl = getUrlByRouteRole('org.openedx.frontend.role.dashboard');
return camelCaseObject({
redirectUrl: data.redirect_url || `${getConfig().LMS_BASE_URL}/dashboard`,
redirectUrl: data.redirect_url || defaultRedirectUrl,
success: data.success || false,
});
};

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { camelCaseObject, logError, logInfo } from '@openedx/frontend-base';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
@@ -12,12 +11,9 @@ import {
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from './constants';
// Mock the dependencies
jest.mock('@edx/frontend-platform/logging', () => ({
jest.mock('@openedx/frontend-base', () => ({
logError: jest.fn(),
logInfo: jest.fn(),
}));
jest.mock('@edx/frontend-platform/utils', () => ({
camelCaseObject: jest.fn(),
}));

View File

@@ -1,5 +1,4 @@
import { logError, logInfo } from '@edx/frontend-platform/logging';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { camelCaseObject, logError, logInfo } from '@openedx/frontend-base';
import { useMutation } from '@tanstack/react-query';
import { login } from './api';
@@ -7,17 +6,17 @@ import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR } from './constants';
// Type definitions
interface LoginData {
email_or_username: string;
password: string;
email_or_username: string,
password: string,
}
interface LoginResponse {
redirectUrl?: string;
redirectUrl?: string,
}
interface UseLoginOptions {
onSuccess?: (data: LoginResponse) => void;
onError?: (error: unknown) => void;
onSuccess?: (data: LoginResponse) => void,
onError?: (error: unknown) => void,
}
const useLogin = (options: UseLoginOptions = {}) => useMutation<LoginResponse, unknown, LoginData>({
@@ -36,10 +35,10 @@ const useLogin = (options: UseLoginOptions = {}) => useMutation<LoginResponse, u
count: 0,
};
if (error && typeof error === 'object' && 'response' in error && error.response) {
const response = error.response as { status?: number; data?: unknown };
const response = error.response as { status?: number, data?: unknown };
const { status, data } = camelCaseObject(response);
if (data && typeof data === 'object') {
const errorData = data as { errorCode?: string; context?: { failureCount?: number } };
const errorData = data as { errorCode?: string, context?: { failureCount?: number } };
formattedError = {
type: errorData.errorCode || FORBIDDEN_REQUEST,
context: errorData.context || {},

Some files were not shown because too many files have changed in this diff Show More