├── .dockerignore ├── .env ├── .env.development ├── .env.test ├── .eslintignore ├── .eslintrc.js ├── .github ├── pull_request_template.md └── workflows │ ├── add-depr-ticket-to-depr-board.yml │ ├── add-remove-label-on-comment.yml │ ├── ci.yml │ ├── commitlint.yml │ ├── lockfileversion-check.yml │ ├── self-assign-issue.yml │ └── update-browserslist-db.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── LICENSE ├── Makefile ├── README.rst ├── __mocks__ ├── fileMock.js └── svgrMock.js ├── catalog-info.yaml ├── docs ├── decisions │ ├── 0001-record-architecture-decisions.rst │ └── 0002-coaching-addition.rst └── images │ └── localhost_preview.png ├── jest.config.js ├── package-lock.json ├── package.json ├── public └── index.html ├── renovate.json └── src ├── account-settings ├── AccountSettingsPage.jsx ├── AccountSettingsPage.messages.jsx ├── Alert.jsx ├── BetaLanguageBanner.jsx ├── DOBForm.jsx ├── EditableField.jsx ├── EditableSelectField.jsx ├── EmailField.jsx ├── JumpNav.jsx ├── NotFoundPage.jsx ├── OneTimeDismissibleAlert.jsx ├── PageLoading.jsx ├── SwitchContent.jsx ├── _style.scss ├── certificate-preference │ ├── CertificatePreference.jsx │ ├── data │ │ └── service.js │ ├── messages.js │ └── test │ │ ├── CertificatePreference.test.jsx │ │ └── __snapshots__ │ │ └── CertificatePreference.test.jsx.snap ├── data │ ├── actions.js │ ├── constants.js │ ├── reducers.js │ ├── sagas.js │ ├── selectors.js │ ├── selectors.test.js │ ├── service.js │ └── utils │ │ ├── __snapshots__ │ │ └── reduxUtils.test.js.snap │ │ ├── index.js │ │ ├── reduxUtils.js │ │ ├── reduxUtils.test.js │ │ ├── sagaUtils.js │ │ └── serviceUtils.js ├── delete-account │ ├── BeforeProceedingBanner.jsx │ ├── BeforeProceedingBanner.test.jsx │ ├── ConfirmationModal.jsx │ ├── ConfirmationModal.test.jsx │ ├── DeleteAccount.jsx │ ├── DeleteAccount.test.jsx │ ├── PrintingInstructions.jsx │ ├── SuccessModal.jsx │ ├── SuccessModal.test.jsx │ ├── __snapshots__ │ │ ├── BeforeProceedingBanner.test.jsx.snap │ │ ├── ConfirmationModal.test.jsx.snap │ │ ├── DeleteAccount.test.jsx.snap │ │ └── SuccessModal.test.jsx.snap │ ├── data │ │ ├── actions.js │ │ ├── reducers.js │ │ ├── reducers.test.js │ │ ├── sagas.js │ │ └── service.js │ ├── index.js │ └── messages.js ├── hoc.jsx ├── hoc.test.jsx ├── index.js ├── name-change │ ├── NameChange.jsx │ ├── data │ │ ├── actions.js │ │ ├── reducers.js │ │ ├── sagas.js │ │ └── service.js │ ├── index.js │ ├── messages.js │ └── test │ │ └── NameChange.test.jsx ├── reset-password │ ├── ConfirmationAlert.jsx │ ├── RequestInProgressAlert.jsx │ ├── ResetPassword.jsx │ ├── data │ │ ├── actions.js │ │ ├── reducers.js │ │ ├── sagas.js │ │ └── service.js │ ├── index.js │ └── messages.js ├── site-language │ ├── actions.js │ ├── constants.js │ ├── index.js │ ├── reducers.js │ ├── sagas.js │ ├── selectors.js │ └── service.js ├── test │ ├── AccountSettingsPage.test.jsx │ ├── EditableSelectField.test.jsx │ ├── JumpNav.test.jsx │ ├── __snapshots__ │ │ └── EditableSelectField.test.jsx.snap │ └── mockData.js └── third-party-auth │ ├── ThirdPartyAuth.jsx │ ├── data │ ├── actions.js │ ├── reducers.js │ ├── sagas.js │ └── service.js │ └── index.js ├── constants.js ├── data ├── configureStore.js ├── reducers.js └── sagas.js ├── divider ├── Divider.jsx └── index.jsx ├── head ├── Head.jsx ├── Head.test.jsx └── messages.js ├── hooks.js ├── i18n └── index.js ├── id-verification ├── AccessBlocked.jsx ├── Camera.jsx ├── CameraHelp.jsx ├── CameraHelpWithUpload.jsx ├── CollapsibleImageHelp.jsx ├── IdVerification.messages.js ├── IdVerificationContext.jsx ├── IdVerificationContextProvider.jsx ├── IdVerificationPage.jsx ├── ImageFileUpload.jsx ├── ImagePreview.jsx ├── SupportedMediaTypes.jsx ├── VerifiedNameContext.jsx ├── _id-verification.scss ├── assets │ └── example-card.png ├── data │ ├── camera-shutter.base64.json │ ├── selectors.js │ └── service.js ├── getUserMediaShim.js ├── index.js ├── panels │ ├── BasePanel.jsx │ ├── EnableCameraDirectionsPanel.jsx │ ├── GetNameIdPanel.jsx │ ├── IdContextPanel.jsx │ ├── PortraitPhotoContextPanel.jsx │ ├── RequestCameraAccessPanel.jsx │ ├── ReviewRequirementsPanel.jsx │ ├── SubmittedPanel.jsx │ ├── SummaryPanel.jsx │ ├── TakeIdPhotoPanel.jsx │ ├── TakePortraitPhotoPanel.jsx │ └── UnsupportedCameraDirectionsPanel.jsx ├── routing-utilities.js └── tests │ ├── AccessBlocked.test.jsx │ ├── Camera.test.jsx │ ├── CollapsibleImageHelp.test.jsx │ ├── IdVerificationContextProvider.test.jsx │ ├── IdVerificationPage.test.jsx │ ├── VerifiedNameContextProvider.test.jsx │ └── panels │ ├── GetNameIdPanel.test.jsx │ ├── IdContextPanel.test.jsx │ ├── PortraitPhotoContextPanel.test.jsx │ ├── RequestCameraAccessPanel.test.jsx │ ├── ReviewRequirementsPanel.test.jsx │ ├── SubmittedPanel.test.jsx │ ├── SummaryPanel.test.jsx │ ├── TakeIdPhotoPanel.test.jsx │ └── TakePortraitPhotoPanel.test.jsx ├── index.jsx ├── index.scss ├── logo.svg ├── notification-preferences ├── EmailCadences.jsx ├── NotificationCoursesDropdown.jsx ├── NotificationPreferenceApp.jsx ├── NotificationPreferenceColumn.jsx ├── NotificationPreferences.jsx ├── NotificationPreferences.test.jsx ├── NotificationSettings.jsx ├── NotificationTypes.jsx ├── ToggleSwitch.jsx ├── data │ ├── actions.js │ ├── constants.js │ ├── reducers.js │ ├── reducers.test.js │ ├── selectors.js │ ├── service.js │ ├── thunk.test.js │ ├── thunks.js │ └── utils.js └── messages.js ├── plugin-slots ├── FooterSlot │ ├── README.md │ └── images │ │ ├── custom_footer.png │ │ └── default_footer.png ├── IdVerificationPageSlot │ ├── README.md │ ├── images │ │ ├── custom_id-verification-page.png │ │ └── default_id-verification-page.png │ └── index.jsx └── README.md ├── setupTest.js ├── tests ├── MockedPluginSlot.jsx ├── MockedPluginSlot.test.jsx ├── hooks.test.jsx └── utils.test.js └── utils.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | README.md 4 | LICENSE 5 | .babelrc 6 | .eslintignore 7 | .eslintrc.json 8 | .gitignore 9 | .npmignore 10 | commitlint.config.js 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ACCESS_TOKEN_COOKIE_NAME='' 2 | ACCOUNT_PROFILE_URL='' 3 | BASE_URL='' 4 | CREDENTIALS_BASE_URL='' 5 | CSRF_TOKEN_API_PATH='' 6 | DISCOVERY_API_BASE_URL='' 7 | ECOMMERCE_BASE_URL='' 8 | FAVICON_URL='' 9 | LANGUAGE_PREFERENCE_COOKIE_NAME='' 10 | LMS_BASE_URL='' 11 | LOGIN_URL='' 12 | LOGO_TRADEMARK_URL='' 13 | LOGO_URL='' 14 | LOGO_WHITE_URL='' 15 | SHOW_EMAIL_CHANNEL='' 16 | SHOW_IMMEDIATE_EMAIL_CADENCE='' 17 | LOGOUT_URL='' 18 | MARKETING_SITE_BASE_URL='' 19 | NODE_ENV='production' 20 | ORDER_HISTORY_URL='' 21 | PUBLISHER_BASE_URL='' 22 | REFRESH_ACCESS_TOKEN_ENDPOINT='' 23 | SEGMENT_KEY='' 24 | SITE_NAME='' 25 | STUDIO_BASE_URL='' 26 | SUPPORT_URL='' 27 | USER_INFO_COOKIE_NAME='' 28 | ENABLE_COPPA_COMPLIANCE='' 29 | ENABLE_ACCOUNT_DELETION='' 30 | ENABLE_DOB_UPDATE='' 31 | MARKETING_EMAILS_OPT_IN='' 32 | APP_ID= 33 | MFE_CONFIG_API_URL= 34 | PASSWORD_RESET_SUPPORT_LINK='' 35 | LEARNER_FEEDBACK_URL='' 36 | SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account' 37 | COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]' 38 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' 2 | ACCOUNT_PROFILE_URL='http://localhost:1995' 3 | BASE_URL='localhost:1997' 4 | CREDENTIALS_BASE_URL='http://localhost:18150' 5 | CSRF_TOKEN_API_PATH='/csrf/api/v1/token' 6 | DISCOVERY_API_BASE_URL='' 7 | ECOMMERCE_BASE_URL='http://localhost:18130' 8 | FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico 9 | LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' 10 | LMS_BASE_URL='http://localhost:18000' 11 | LOGIN_URL='http://localhost:18000/login' 12 | LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg 13 | LOGO_URL=https://edx-cdn.org/v3/default/logo.svg 14 | LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg 15 | LOGOUT_URL='http://localhost:18000/logout' 16 | MARKETING_SITE_BASE_URL='http://localhost:5335' 17 | NODE_ENV='development' 18 | ORDER_HISTORY_URL='http://localhost:1996/orders' 19 | PORT=1997 20 | PUBLISHER_BASE_URL='' 21 | REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' 22 | SEGMENT_KEY='' 23 | SITE_NAME=localhost 24 | STUDIO_BASE_URL='' 25 | SUPPORT_URL='http://localhost:18000/support' 26 | USER_INFO_COOKIE_NAME='edx-user-info' 27 | ENABLE_COPPA_COMPLIANCE='' 28 | ENABLE_ACCOUNT_DELETION='' 29 | ENABLE_DOB_UPDATE='' 30 | MARKETING_EMAILS_OPT_IN='' 31 | SHOW_EMAIL_CHANNEL='true' 32 | SHOW_IMMEDIATE_EMAIL_CADENCE='true' 33 | APP_ID= 34 | MFE_CONFIG_API_URL= 35 | PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com' 36 | LEARNER_FEEDBACK_URL='' 37 | SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account' 38 | COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]' 39 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' 2 | BASE_URL='localhost:1997' 3 | CREDENTIALS_BASE_URL='http://localhost:18150' 4 | CSRF_TOKEN_API_PATH='/csrf/api/v1/token' 5 | DISCOVERY_API_BASE_URL='' 6 | ECOMMERCE_BASE_URL='http://localhost:18130' 7 | FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico 8 | LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' 9 | LMS_BASE_URL='http://localhost:18000' 10 | LOGIN_URL='http://localhost:18000/login' 11 | LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg 12 | LOGO_URL=https://edx-cdn.org/v3/default/logo.svg 13 | LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg 14 | LOGOUT_URL='http://localhost:18000/logout' 15 | MARKETING_SITE_BASE_URL='http://localhost:5335' 16 | NODE_ENV='' 17 | ORDER_HISTORY_URL='http://localhost:1996/orders' 18 | PUBLISHER_BASE_URL='' 19 | REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' 20 | SEGMENT_KEY='' 21 | SITE_NAME=localhost 22 | STUDIO_BASE_URL='' 23 | SUPPORT_URL='http://localhost:18000/support' 24 | USER_INFO_COOKIE_NAME='edx-user-info' 25 | ENABLE_COPPA_COMPLIANCE='' 26 | ENABLE_ACCOUNT_DELETION='' 27 | SHOW_EMAIL_CHANNEL='' 28 | SHOW_IMMEDIATE_EMAIL_CADENCE='' 29 | ENABLE_DOB_UPDATE='' 30 | MARKETING_EMAILS_OPT_IN='' 31 | APP_ID= 32 | MFE_CONFIG_API_URL= 33 | LEARNER_FEEDBACK_URL='' 34 | SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account' 35 | COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]' 36 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/* 2 | dist/ 3 | node_modules/ 4 | __mocks__/ 5 | __snapshots__/ 6 | src/i18n/messages/ 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const { createConfig } = require('@openedx/frontend-build'); 3 | 4 | module.exports = createConfig('eslint'); 5 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues. 4 | 5 | #### How Has This Been Tested? 6 | 7 | Please describe in detail how you tested your changes. 8 | 9 | #### Screenshots/sandbox (optional): 10 | Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.** 11 | 12 | |Before|After| 13 | |-------|-----| 14 | | | | 15 | 16 | #### Merge Checklist 17 | 18 | * [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable. 19 | * [ ] Is there adequate test coverage for your changes? 20 | 21 | #### Post-merge Checklist 22 | 23 | * [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it. 24 | * [ ] 🎉 🙌 Celebrate! Thanks for your contribution. -------------------------------------------------------------------------------- /.github/workflows/add-depr-ticket-to-depr-board.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are either: 2 | # - labelled "DEPR" 3 | # - title starts with "[DEPR]" 4 | # - body starts with "Proposal Date" (this is the first template field) 5 | # to the org-wide DEPR project board 6 | 7 | name: Add newly created DEPR issues to the DEPR project board 8 | 9 | on: 10 | issues: 11 | types: [opened] 12 | 13 | jobs: 14 | routeissue: 15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/add-remove-label-on-comment.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "label: " it tries to apply 3 | # the label indicated in rest of comment. 4 | # If the comment starts with "remove label: ", it tries 5 | # to remove the indicated label. 6 | # Note: Labels are allowed to have spaces and this script does 7 | # not parse spaces (as often a space is legitimate), so the command 8 | # "label: really long lots of words label" will apply the 9 | # label "really long lots of words label" 10 | 11 | name: Allows for the adding and removing of labels via comment 12 | 13 | on: 14 | issue_comment: 15 | types: [created] 16 | 17 | jobs: 18 | add_remove_labels: 19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | npm-test: 13 | - i18n_extract 14 | - lint 15 | - test 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version-file: '.nvmrc' 21 | - run: make requirements 22 | - run: make test NPM_TESTS=build 23 | - run: make test NPM_TESTS=${{ matrix.npm-test }} 24 | - name: Coverage 25 | if: matrix.npm-test == 'test' 26 | uses: codecov/codecov-action@v4 27 | with: 28 | token: ${{ secrets.CODECOV_TOKEN }} 29 | fail_ci_if_error: true 30 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | # Run commitlint on the commit messages in a pull request. 2 | 3 | name: Lint Commit Messages 4 | 5 | on: 6 | - pull_request 7 | 8 | jobs: 9 | commitlint: 10 | uses: openedx/.github/.github/workflows/commitlint.yml@master 11 | -------------------------------------------------------------------------------- /.github/workflows/lockfileversion-check.yml: -------------------------------------------------------------------------------- 1 | #check package-lock file version 2 | 3 | name: Lockfile Version check 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | 11 | jobs: 12 | version-check: 13 | uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master 14 | -------------------------------------------------------------------------------- /.github/workflows/self-assign-issue.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "assign me" it assigns the author to the 3 | # ticket (case insensitive) 4 | 5 | name: Assign comment author to ticket if they say "assign me" 6 | on: 7 | issue_comment: 8 | types: [created] 9 | 10 | jobs: 11 | self_assign_by_comment: 12 | uses: openedx/.github/.github/workflows/self-assign-issue.yml@master 13 | -------------------------------------------------------------------------------- /.github/workflows/update-browserslist-db.yml: -------------------------------------------------------------------------------- 1 | name: Update Browserslist DB 2 | on: 3 | schedule: 4 | - cron: '0 0 * * 1' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | update-browserslist: 9 | uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master 10 | 11 | secrets: 12 | requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .eslintcache 3 | .idea 4 | node_modules 5 | npm-debug.log 6 | coverage 7 | 8 | dist/ 9 | src/i18n/transifex_input.json 10 | temp/babel-plugin-react-intl 11 | 12 | ### pyenv ### 13 | .python-version 14 | 15 | ### Emacs ### 16 | *~ 17 | /temp 18 | /.vscode 19 | /module.config.js 20 | src/i18n/messages/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .tx 2 | coverage 3 | dist 4 | node_modules 5 | public 6 | src 7 | .dockerignore 8 | .eslintignore 9 | .eslintrc 10 | .gitignore 11 | .releaserc 12 | .travis.yml 13 | babel.config.js 14 | Makefile 15 | renovate.json 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | intl_imports = ./node_modules/.bin/intl-imports.js 2 | transifex_utils = ./node_modules/.bin/transifex-utils.js 3 | i18n = ./src/i18n 4 | transifex_input = $(i18n)/transifex_input.json 5 | 6 | # This directory must match .babelrc . 7 | transifex_temp = ./temp/babel-plugin-formatjs 8 | 9 | NPM_TESTS=build i18n_extract lint test 10 | 11 | .PHONY: test 12 | test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite 13 | 14 | .PHONY: test.npm.* 15 | test.npm.%: validate-no-uncommitted-package-lock-changes 16 | test -d node_modules || $(MAKE) requirements 17 | npm run $(*) 18 | 19 | .PHONY: requirements 20 | 21 | precommit: 22 | npm run lint 23 | npm audit 24 | 25 | requirements: ## install ci requirements 26 | npm ci 27 | 28 | i18n.extract: 29 | # Pulling display strings from .jsx files into .json files... 30 | rm -rf $(transifex_temp) 31 | npm run-script i18n_extract 32 | 33 | i18n.concat: 34 | # Gathering JSON messages into one file... 35 | $(transifex_utils) $(transifex_temp) $(transifex_input) 36 | 37 | extract_translations: | requirements i18n.extract i18n.concat 38 | 39 | # Despite the name, we actually need this target to detect changes in the incoming translated message files as well. 40 | detect_changed_source_translations: 41 | # Checking for changed translations... 42 | git diff --exit-code $(i18n) 43 | 44 | # Pushes translations to Transifex. You must run make extract_translations first. 45 | push_translations: 46 | # Pushing strings to Transifex... 47 | tx push -s 48 | # Fetching hashes from Transifex... 49 | ./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh 50 | # Writing out comments to file... 51 | $(transifex_utils) $(transifex_temp) --comments --v3-scripts-path 52 | # Pushing comments to Transifex... 53 | ./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh 54 | 55 | pull_translations: 56 | rm -rf src/i18n/messages 57 | mkdir src/i18n/messages 58 | cd src/i18n/messages \ 59 | && atlas pull $(ATLAS_OPTIONS) \ 60 | translations/frontend-platform/src/i18n/messages:frontend-platform \ 61 | translations/paragon/src/i18n/messages:paragon \ 62 | translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ 63 | translations/frontend-component-header/src/i18n/messages:frontend-component-header \ 64 | translations/frontend-app-account/src/i18n/messages:frontend-app-account 65 | 66 | $(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-account 67 | 68 | # This target is used by Travis. 69 | validate-no-uncommitted-package-lock-changes: 70 | # Checking for package-lock.json changes... 71 | git diff --exit-code package-lock.json 72 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /__mocks__/svgrMock.js: -------------------------------------------------------------------------------- 1 | module.exports = { ReactComponent: 'IconMock' }; 2 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # This file records information about this repo. Its use is described in OEP-55: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html 3 | 4 | apiVersion: backstage.io/v1alpha1 5 | kind: Component 6 | metadata: 7 | name: 'frontend-app-account' 8 | description: "Open edX micro-frontend application for managing user account information." 9 | links: 10 | - url: "https://github.com/openedx/frontend-app-account" 11 | title: "Frontend app account" 12 | icon: "Web" 13 | annotations: 14 | openedx.org/arch-interest-groups: "" 15 | openedx.org/release: "master" 16 | spec: 17 | owner: group:2u-infinity 18 | type: 'website' 19 | lifecycle: 'production' 20 | -------------------------------------------------------------------------------- /docs/decisions/0001-record-architecture-decisions.rst: -------------------------------------------------------------------------------- 1 | 1. Record Architecture Decisions 2 | -------------------------------- 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | We would like to keep a historical record on the architectural 13 | decisions we make with this app as it evolves over time. 14 | 15 | Decision 16 | -------- 17 | 18 | We will use Architecture Decision Records, as described by 19 | Michael Nygard in `Documenting Architecture Decisions`_ 20 | 21 | .. _Documenting Architecture Decisions: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions 22 | 23 | Consequences 24 | ------------ 25 | 26 | See Michael Nygard's article, linked above. 27 | 28 | References 29 | ---------- 30 | 31 | * https://resources.sei.cmu.edu/asset_files/Presentation/2017_017_001_497746.pdf 32 | * https://github.com/npryce/adr-tools/tree/master/doc/adr 33 | -------------------------------------------------------------------------------- /docs/decisions/0002-coaching-addition.rst: -------------------------------------------------------------------------------- 1 | 1. Add Coaching Consent 2 | -------------------------------- 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | We need to provide users who are eligible for coaching with both an always available 13 | coaching toggle and a one-time form they can view to signup for coaching. 14 | 15 | Decision 16 | -------- 17 | 18 | While the coaching functionality is currently both limited, closed source, and the form 19 | exists outside of the standard design of this MFE, it was decided to add it here as a 20 | temporary measure due to it being at it's core, an account setting. 21 | 22 | The longer term solutions include either: 23 | - using the frontend plugins feature when they become available to inject our coaching 24 | work into the account MFE 25 | - roll it into it's own MFE if enough additional coaching frontend work is required 26 | 27 | Consequences 28 | ------------ 29 | 30 | Code will exist inside this Open edX MFE that integrates with a closed source app. 31 | -------------------------------------------------------------------------------- /docs/images/localhost_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-app-account/d0eebfa0ea79d811425b195f54ce821ef9ab7508/docs/images/localhost_preview.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { createConfig } = require('@openedx/frontend-build'); 2 | 3 | module.exports = createConfig('jest', { 4 | setupFilesAfterEnv: [ 5 | '/src/setupTest.js', 6 | ], 7 | }); 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "schedule:weekly", 5 | ":automergeLinters", 6 | ":automergeMinor", 7 | ":automergeTesters", 8 | ":enableVulnerabilityAlerts", 9 | ":rebaseStalePrs", 10 | ":semanticCommits", 11 | ":updateNotScheduled" 12 | ], 13 | "packageRules": [ 14 | { 15 | "matchDepTypes": [ 16 | "devDependencies" 17 | ], 18 | "matchUpdateTypes": [ 19 | "lockFileMaintenance", 20 | "minor", 21 | "patch", 22 | "pin" 23 | ], 24 | "automerge": true 25 | }, 26 | { 27 | "matchPackagePatterns": ["@edx", "@openedx"], 28 | "matchUpdateTypes": ["minor", "patch"], 29 | "automerge": true 30 | } 31 | ], 32 | "timezone": "America/New_York" 33 | } 34 | -------------------------------------------------------------------------------- /src/account-settings/Alert.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | const Alert = (props) => ( 6 |
7 |
8 | {props.icon} 9 |
10 |
11 | {props.children} 12 |
13 |
14 | ); 15 | 16 | Alert.propTypes = { 17 | className: PropTypes.string, 18 | icon: PropTypes.node, 19 | children: PropTypes.node, 20 | }; 21 | 22 | Alert.defaultProps = { 23 | className: undefined, 24 | icon: undefined, 25 | children: undefined, 26 | }; 27 | 28 | export default Alert; 29 | -------------------------------------------------------------------------------- /src/account-settings/JumpNav.jsx: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 3 | import { breakpoints, useWindowSize } from '@openedx/paragon'; 4 | import classNames from 'classnames'; 5 | import React from 'react'; 6 | import { NavHashLink } from 'react-router-hash-link'; 7 | import Scrollspy from 'react-scrollspy'; 8 | import messages from './AccountSettingsPage.messages'; 9 | 10 | const JumpNav = ({ 11 | intl, 12 | }) => { 13 | const stickToTop = useWindowSize().width > breakpoints.small.minWidth; 14 | 15 | return ( 16 |
17 | 31 |
  • 32 | 33 | {intl.formatMessage(messages['account.settings.section.account.information'])} 34 | 35 |
  • 36 |
  • 37 | 38 | {intl.formatMessage(messages['account.settings.section.profile.information'])} 39 | 40 |
  • 41 |
  • 42 | 43 | {intl.formatMessage(messages['account.settings.section.social.media'])} 44 | 45 |
  • 46 |
  • 47 | 48 | {intl.formatMessage(messages['notification.preferences.notifications.label'])} 49 | 50 |
  • 51 |
  • 52 | 53 | {intl.formatMessage(messages['account.settings.section.site.preferences'])} 54 | 55 |
  • 56 |
  • 57 | 58 | {intl.formatMessage(messages['account.settings.section.linked.accounts'])} 59 | 60 |
  • 61 | {getConfig().ENABLE_ACCOUNT_DELETION 62 | && ( 63 |
  • 64 | 65 | {intl.formatMessage(messages['account.settings.jump.nav.delete.account'])} 66 | 67 |
  • 68 | )} 69 |
    70 |
    71 | ); 72 | }; 73 | 74 | JumpNav.propTypes = { 75 | intl: intlShape.isRequired, 76 | }; 77 | 78 | export default injectIntl(JumpNav); 79 | -------------------------------------------------------------------------------- /src/account-settings/NotFoundPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage } from '@edx/frontend-platform/i18n'; 3 | 4 | const NotFoundPage = () => ( 5 |
    9 |

    10 | 15 |

    16 |
    17 | ); 18 | 19 | export default NotFoundPage; 20 | -------------------------------------------------------------------------------- /src/account-settings/OneTimeDismissibleAlert.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Alert } from '@openedx/paragon'; 5 | 6 | const OneTimeDismissibleAlert = (props) => { 7 | const [dismissed, setDismissed] = useState(localStorage.getItem(props.id) !== 'true'); 8 | 9 | const onClose = () => { 10 | localStorage.setItem(props.id, 'true'); 11 | setDismissed(false); 12 | }; 13 | 14 | return ( 15 | 22 | {props.header} 23 |

    24 | {props.body} 25 |

    26 |
    27 | ); 28 | }; 29 | 30 | OneTimeDismissibleAlert.propTypes = { 31 | id: PropTypes.string.isRequired, 32 | variant: PropTypes.string, 33 | icon: PropTypes.func, 34 | header: PropTypes.string, 35 | body: PropTypes.string, 36 | }; 37 | 38 | OneTimeDismissibleAlert.defaultProps = { 39 | variant: 'success', 40 | icon: undefined, 41 | header: undefined, 42 | body: undefined, 43 | }; 44 | 45 | export default OneTimeDismissibleAlert; 46 | -------------------------------------------------------------------------------- /src/account-settings/PageLoading.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class PageLoading extends Component { 5 | renderSrMessage() { 6 | if (!this.props.srMessage) { 7 | return null; 8 | } 9 | 10 | return ( 11 | 12 | {this.props.srMessage} 13 | 14 | ); 15 | } 16 | 17 | render() { 18 | return ( 19 |
    20 |
    26 |
    27 | {this.renderSrMessage()} 28 |
    29 |
    30 |
    31 | ); 32 | } 33 | } 34 | 35 | PageLoading.propTypes = { 36 | srMessage: PropTypes.string.isRequired, 37 | }; 38 | -------------------------------------------------------------------------------- /src/account-settings/SwitchContent.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { TransitionReplace } from '@openedx/paragon'; 4 | 5 | const onChildExit = (htmlNode) => { 6 | // If the leaving child has focus, take control and redirect it 7 | if (htmlNode.contains(document.activeElement)) { 8 | // Get the newly entering sibling. 9 | // It's the previousSibling, but not for any explicit reason. So checking for both. 10 | const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling; 11 | 12 | // There's no replacement, do nothing. 13 | if (!enteringChild) { 14 | return; 15 | } 16 | 17 | // Get all the focusable elements in the entering child and focus the first one 18 | const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); 19 | if (focusableElements.length) { 20 | focusableElements[0].focus(); 21 | } 22 | } 23 | }; 24 | 25 | const SwitchContent = ({ expression, cases, className }) => { 26 | const getContent = (caseKey) => { 27 | if (cases[caseKey]) { 28 | if (typeof cases[caseKey] === 'string') { 29 | return getContent(cases[caseKey]); 30 | } 31 | return React.cloneElement(cases[caseKey], { key: caseKey }); 32 | } 33 | if (cases.default) { 34 | if (typeof cases.default === 'string') { 35 | return getContent(cases.default); 36 | } 37 | React.cloneElement(cases.default, { key: 'default' }); 38 | } 39 | 40 | return null; 41 | }; 42 | 43 | return ( 44 | 48 | {getContent(expression)} 49 | 50 | ); 51 | }; 52 | 53 | SwitchContent.propTypes = { 54 | expression: PropTypes.string, 55 | cases: PropTypes.objectOf(PropTypes.node).isRequired, 56 | className: PropTypes.string, 57 | }; 58 | 59 | SwitchContent.defaultProps = { 60 | expression: null, 61 | className: null, 62 | }; 63 | 64 | export default SwitchContent; 65 | -------------------------------------------------------------------------------- /src/account-settings/_style.scss: -------------------------------------------------------------------------------- 1 | .page__account-settings { 2 | .form-group { 3 | margin-bottom: 1.5rem; 4 | } 5 | 6 | h6, .h6 { 7 | margin-bottom: .25rem; 8 | } 9 | 10 | .btn-link { 11 | line-height: 1.2; 12 | border: none; 13 | padding: 0; 14 | display: inline-block; 15 | } 16 | 17 | .jump-nav-sm { 18 | top: 1rem; 19 | } 20 | 21 | .jump-nav { 22 | 23 | li { 24 | margin-bottom: .5rem; 25 | 26 | a { 27 | text-decoration: underline; 28 | } 29 | } 30 | } 31 | 32 | 33 | .custom-switch { 34 | padding: 0; 35 | max-width: 500px; 36 | 37 | .custom-control-label { 38 | left: 2.25rem; 39 | line-height: 1.6rem; 40 | } 41 | } 42 | .grayed-out{ 43 | opacity: 0.6; /* Real browsers */ 44 | filter: alpha(opacity = 60); /* MSIE */ 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/account-settings/certificate-preference/data/service.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | 4 | import { handleRequestError } from '../../data/utils'; 5 | 6 | // eslint-disable-next-line import/prefer-default-export 7 | export async function postVerifiedNameConfig(username, commitValues) { 8 | const requestConfig = { headers: { Accept: 'application/json' } }; 9 | const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name/config`; 10 | 11 | const { useVerifiedNameForCerts } = commitValues; 12 | const postValues = { 13 | username, 14 | use_verified_name_for_certs: useVerifiedNameForCerts, 15 | }; 16 | 17 | const { data } = await getAuthenticatedHttpClient() 18 | .post(requestUrl, postValues, requestConfig) 19 | .catch(error => handleRequestError(error)); 20 | 21 | return data; 22 | } 23 | -------------------------------------------------------------------------------- /src/account-settings/certificate-preference/messages.js: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | 'account.settings.field.name.checkbox.certificate.select': { 5 | id: 'account.settings.field.name.certificate.select', 6 | defaultMessage: 'If checked, this name will appear on your certificates and public-facing records.', 7 | description: 'Label for checkbox describing that the selected name will appear on the user‘s certificates.', 8 | }, 9 | 'account.settings.field.name.modal.certificate.title': { 10 | id: 'account.settings.field.name.modal.certificate.title', 11 | defaultMessage: 'Choose a preferred name for certificates and public-facing records', 12 | description: 'Title instructing the user to choose a preferred name.', 13 | }, 14 | 'account.settings.field.name.modal.certificate.select': { 15 | id: 'account.settings.field.name.modal.certificate.select', 16 | defaultMessage: 'Select a name', 17 | description: 'Label instructing the user to select a name.', 18 | }, 19 | 'account.settings.field.name.modal.certificate.option.full': { 20 | id: 'account.settings.field.name.modal.certificate.option.full', 21 | defaultMessage: 'Full Name', 22 | description: 'Option representing the user’s full name.', 23 | }, 24 | 'account.settings.field.name.modal.certificate.option.verified': { 25 | id: 'account.settings.field.name.modal.certificate.option.verified', 26 | defaultMessage: 'Verified Name', 27 | description: 'Option representing the user’s verified name.', 28 | }, 29 | 'account.settings.field.name.modal.certificate.button.choose': { 30 | id: 'account.settings.field.name.modal.certificate.button.choose', 31 | defaultMessage: 'Choose name', 32 | description: 'Button to confirm the user’s name choice.', 33 | }, 34 | }); 35 | 36 | export default messages; 37 | -------------------------------------------------------------------------------- /src/account-settings/certificate-preference/test/__snapshots__/CertificatePreference.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`NameChange does not render if there is no verified name 1`] = ` 4 | { 5 | "asFragment": [Function], 6 | "baseElement": 7 |
    8 | , 9 | "container":
    , 10 | "debug": [Function], 11 | "findAllByAltText": [Function], 12 | "findAllByDisplayValue": [Function], 13 | "findAllByLabelText": [Function], 14 | "findAllByPlaceholderText": [Function], 15 | "findAllByRole": [Function], 16 | "findAllByTestId": [Function], 17 | "findAllByText": [Function], 18 | "findAllByTitle": [Function], 19 | "findByAltText": [Function], 20 | "findByDisplayValue": [Function], 21 | "findByLabelText": [Function], 22 | "findByPlaceholderText": [Function], 23 | "findByRole": [Function], 24 | "findByTestId": [Function], 25 | "findByText": [Function], 26 | "findByTitle": [Function], 27 | "getAllByAltText": [Function], 28 | "getAllByDisplayValue": [Function], 29 | "getAllByLabelText": [Function], 30 | "getAllByPlaceholderText": [Function], 31 | "getAllByRole": [Function], 32 | "getAllByTestId": [Function], 33 | "getAllByText": [Function], 34 | "getAllByTitle": [Function], 35 | "getByAltText": [Function], 36 | "getByDisplayValue": [Function], 37 | "getByLabelText": [Function], 38 | "getByPlaceholderText": [Function], 39 | "getByRole": [Function], 40 | "getByTestId": [Function], 41 | "getByText": [Function], 42 | "getByTitle": [Function], 43 | "queryAllByAltText": [Function], 44 | "queryAllByDisplayValue": [Function], 45 | "queryAllByLabelText": [Function], 46 | "queryAllByPlaceholderText": [Function], 47 | "queryAllByRole": [Function], 48 | "queryAllByTestId": [Function], 49 | "queryAllByText": [Function], 50 | "queryAllByTitle": [Function], 51 | "queryByAltText": [Function], 52 | "queryByDisplayValue": [Function], 53 | "queryByLabelText": [Function], 54 | "queryByPlaceholderText": [Function], 55 | "queryByRole": [Function], 56 | "queryByTestId": [Function], 57 | "queryByText": [Function], 58 | "queryByTitle": [Function], 59 | "rerender": [Function], 60 | "unmount": [Function], 61 | } 62 | `; 63 | -------------------------------------------------------------------------------- /src/account-settings/data/selectors.test.js: -------------------------------------------------------------------------------- 1 | import { profileDataManagerSelector, formValuesSelector } from './selectors'; 2 | 3 | const testValue = 'test VALUE'; 4 | 5 | describe('profileDataManagerSelector', () => { 6 | it('returns the profileDataManager from the state', () => { 7 | const state = { 8 | accountSettings: { 9 | profileDataManager: { testValue }, 10 | }, 11 | }; 12 | const result = profileDataManagerSelector(state); 13 | 14 | expect(result).toEqual(state.accountSettings.profileDataManager); 15 | }); 16 | 17 | it('should correctly select form values', () => { 18 | const state = { 19 | accountSettings: { 20 | values: { 21 | name: 'John Doe', 22 | age: 25, 23 | }, 24 | drafts: { 25 | age: 26, 26 | 27 | }, 28 | verifiedNameHistory: 'test', 29 | confirmationValues: {}, 30 | }, 31 | }; 32 | 33 | const result = formValuesSelector(state); 34 | 35 | const expected = { 36 | name: 'John Doe', 37 | age: 26, 38 | verified_name: '', 39 | useVerifiedNameForCerts: false, 40 | }; 41 | 42 | expect(result).toEqual(expected); 43 | }); 44 | 45 | it('should correctly select form values with extended_profile', () => { 46 | // Mock data with extended_profile field in both values and drafts 47 | const state = { 48 | accountSettings: { 49 | values: { 50 | extended_profile: [ 51 | { field_name: 'test_field', field_value: '5' }, 52 | ], 53 | }, 54 | drafts: { test_field: '6' }, 55 | verifiedNameHistory: 'test', 56 | confirmationValues: {}, 57 | }, 58 | }; 59 | 60 | const result = formValuesSelector(state); 61 | 62 | const expected = { 63 | verified_name: '', 64 | useVerifiedNameForCerts: false, 65 | extended_profile: [ // Draft value should override the committed value 66 | { field_name: 'test_field', field_value: '6' }, // Value from the committed values 67 | ], 68 | }; 69 | 70 | expect(result).toEqual(expected); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/account-settings/data/utils/__snapshots__/reduxUtils.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getModuleState should throw an exception on a bad path 1`] = `"Unexpected state key uhoh given to getModuleState. Is your state path set up correctly?"`; 4 | -------------------------------------------------------------------------------- /src/account-settings/data/utils/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | AsyncActionType, 3 | getModuleState, 4 | } from './reduxUtils'; 5 | export { default as handleFailure } from './sagaUtils'; 6 | export { unpackFieldErrors, handleRequestError } from './serviceUtils'; 7 | -------------------------------------------------------------------------------- /src/account-settings/data/utils/reduxUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper class to save time when writing out action types for asynchronous methods. Also helps 3 | * ensure that actions are namespaced. 4 | */ 5 | export class AsyncActionType { 6 | constructor(topic, name) { 7 | this.topic = topic; 8 | this.name = name; 9 | } 10 | 11 | get BASE() { 12 | return `${this.topic}__${this.name}`; 13 | } 14 | 15 | get BEGIN() { 16 | return `${this.topic}__${this.name}__BEGIN`; 17 | } 18 | 19 | get SUCCESS() { 20 | return `${this.topic}__${this.name}__SUCCESS`; 21 | } 22 | 23 | get FAILURE() { 24 | return `${this.topic}__${this.name}__FAILURE`; 25 | } 26 | 27 | get RESET() { 28 | return `${this.topic}__${this.name}__RESET`; 29 | } 30 | 31 | get FORBIDDEN() { 32 | return `${this.topic}__${this.name}__FORBIDDEN`; 33 | } 34 | } 35 | 36 | /** 37 | * Given a state tree and an array representing a set of keys to traverse in that tree, returns 38 | * the portion of the tree at that key path. 39 | * 40 | * Example: 41 | * 42 | * const result = getModuleState( 43 | * { 44 | * first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } }, 45 | * second: { other: 'data', } 46 | * }, 47 | * ['first', 'red'] 48 | * ); 49 | * 50 | * result will be: 51 | * 52 | * { 53 | * awesome: 'sauce' 54 | * } 55 | */ 56 | export function getModuleState(state, originalPath) { 57 | const path = [...originalPath]; // don't modify your argument 58 | if (path.length < 1) { 59 | return state; 60 | } 61 | const key = path.shift(); 62 | if (state[key] === undefined) { 63 | throw new Error(`Unexpected state key ${key} given to getModuleState. Is your state path set up correctly?`); 64 | } 65 | return getModuleState(state[key], path); 66 | } 67 | -------------------------------------------------------------------------------- /src/account-settings/data/utils/reduxUtils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncActionType, 3 | getModuleState, 4 | } from './reduxUtils'; 5 | 6 | describe('AsyncActionType', () => { 7 | it('should return well formatted action strings', () => { 8 | const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE'); 9 | 10 | expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE'); 11 | expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN'); 12 | expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS'); 13 | expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE'); 14 | expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET'); 15 | expect(actionType.FORBIDDEN).toBe('HOUSE_CATS__START_THE_RACE__FORBIDDEN'); 16 | }); 17 | }); 18 | 19 | describe('getModuleState', () => { 20 | const state = { 21 | first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } }, 22 | second: { other: 'data' }, 23 | }; 24 | 25 | it('should return everything if given an empty path', () => { 26 | expect(getModuleState(state, [])).toEqual(state); 27 | }); 28 | 29 | it('should resolve paths correctly', () => { 30 | expect(getModuleState( 31 | state, 32 | ['first'], 33 | )).toEqual({ red: { awesome: 'sauce' }, blue: { weak: 'sauce' } }); 34 | 35 | expect(getModuleState( 36 | state, 37 | ['first', 'red'], 38 | )).toEqual({ awesome: 'sauce' }); 39 | 40 | expect(getModuleState(state, ['second'])).toEqual({ other: 'data' }); 41 | }); 42 | 43 | it('should throw an exception on a bad path', () => { 44 | expect(() => { 45 | getModuleState(state, ['uhoh']); 46 | }).toThrowErrorMatchingSnapshot(); 47 | }); 48 | 49 | it('should return non-objects correctly', () => { 50 | expect(getModuleState(state, ['first', 'red', 'awesome'])).toEqual('sauce'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/account-settings/data/utils/sagaUtils.js: -------------------------------------------------------------------------------- 1 | import { put } from 'redux-saga/effects'; 2 | import { logError } from '@edx/frontend-platform/logging'; 3 | 4 | export default function* handleFailure(error, navigate, failureAction = null, failureRedirectPath = null) { 5 | if (error.fieldErrors && failureAction !== null) { 6 | yield put(failureAction({ fieldErrors: error.fieldErrors })); 7 | } 8 | logError(error); 9 | if (failureAction !== null) { 10 | yield put(failureAction(error.message)); 11 | } 12 | if (failureRedirectPath !== null) { 13 | navigate(failureRedirectPath); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/account-settings/data/utils/serviceUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Turns field errors of the form: 3 | * 4 | * { 5 | * "name":{ 6 | * "developer_message": "Nerdy message here", 7 | * "user_message": "This value is invalid." 8 | * }, 9 | * "other_field": { 10 | * "developer_message": "Other Nerdy message here", 11 | * "user_message": "This other value is invalid." 12 | * } 13 | * } 14 | * 15 | * Into: 16 | * 17 | * { 18 | * "name": "This value is invalid.", 19 | * "other_field": "This other value is invalid" 20 | * } 21 | */ 22 | export function unpackFieldErrors(fieldErrors) { 23 | return Object.entries(fieldErrors).reduce((acc, [k, v]) => { 24 | acc[k] = v.user_message; 25 | return acc; 26 | }, {}); 27 | } 28 | 29 | /** 30 | * Processes and re-throws request errors. If the response contains a field_errors field, will 31 | * massage the data into a form expected by the client. 32 | * 33 | * Field errors will be packaged as an api error with a fieldErrors field usable by the client. 34 | * Takes an optional unpack function which is used to process the field errors, 35 | * otherwise uses the default unpackFieldErrors function. 36 | * 37 | * @param error The original error object. 38 | * @param unpackFunction (Optional) A function to use to unpack the field errors as a replacement 39 | * for the default. 40 | */ 41 | export function handleRequestError(error, unpackFunction = unpackFieldErrors) { 42 | if (error.response && error.response.data.field_errors) { 43 | const apiError = Object.create(error); 44 | apiError.fieldErrors = unpackFunction(error.response.data.field_errors); 45 | throw apiError; 46 | } 47 | throw error; 48 | } 49 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/BeforeProceedingBanner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 6 | import { Hyperlink } from '@openedx/paragon'; 7 | 8 | // Messages 9 | import { getConfig } from '@edx/frontend-platform'; 10 | import messages from './messages'; 11 | 12 | // Components 13 | import Alert from '../Alert'; 14 | 15 | const BeforeProceedingBanner = (props) => { 16 | const { instructionMessageId, intl, supportArticleUrl } = props; 17 | 18 | return ( 19 | } 22 | > 23 | 30 | {intl.formatMessage(messages[instructionMessageId])} 31 | 32 | ) : ( 33 | intl.formatMessage(messages[instructionMessageId]) 34 | ), 35 | siteName: getConfig().SITE_NAME, 36 | }} 37 | /> 38 | 39 | ); 40 | }; 41 | 42 | BeforeProceedingBanner.propTypes = { 43 | instructionMessageId: PropTypes.string.isRequired, 44 | intl: intlShape.isRequired, 45 | supportArticleUrl: PropTypes.string.isRequired, 46 | }; 47 | 48 | export default injectIntl(BeforeProceedingBanner); 49 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/BeforeProceedingBanner.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { IntlProvider, injectIntl, createIntl } from '@edx/frontend-platform/i18n'; 4 | 5 | jest.mock('react-dom', () => ({ 6 | ...jest.requireActual('react-dom'), 7 | createPortal: jest.fn(node => node), // Mock portal behavior 8 | })); 9 | 10 | import BeforeProceedingBanner from './BeforeProceedingBanner'; // eslint-disable-line import/first 11 | 12 | const IntlBeforeProceedingBanner = injectIntl(BeforeProceedingBanner); 13 | 14 | describe('BeforeProceedingBanner', () => { 15 | it('should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link', () => { 16 | const props = { 17 | instructionMessageId: 'account.settings.delete.account.please.unlink', 18 | intl: createIntl({ locale: 'en' }), 19 | supportArticleUrl: '', 20 | }; 21 | const tree = renderer 22 | .create(( 23 | 24 | 27 | 28 | )) 29 | .toJSON(); 30 | expect(tree).toMatchSnapshot(); 31 | }); 32 | 33 | it('should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link', () => { 34 | const props = { 35 | instructionMessageId: 'account.settings.delete.account.please.unlink', 36 | intl: createIntl({ locale: 'en' }), 37 | supportArticleUrl: 'http://test-support.edx', 38 | }; 39 | const tree = renderer 40 | .create(( 41 | 42 | 45 | 46 | )) 47 | .toJSON(); 48 | expect(tree).toMatchSnapshot(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/ConfirmationModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; 4 | 5 | // Modal creates a portal. Overriding createPortal allows portals to be tested in jest. 6 | jest.mock('react-dom', () => ({ 7 | ...jest.requireActual('react-dom'), 8 | createPortal: jest.fn(node => node), // Mock portal behavior 9 | })); 10 | 11 | import { ConfirmationModal } from './ConfirmationModal'; // eslint-disable-line import/first 12 | 13 | const IntlConfirmationModal = injectIntl(ConfirmationModal); 14 | 15 | describe('ConfirmationModal', () => { 16 | let props = {}; 17 | 18 | beforeEach(() => { 19 | props = { 20 | onCancel: jest.fn(), 21 | onChange: jest.fn(), 22 | onSubmit: jest.fn(), 23 | status: null, 24 | errorType: null, 25 | password: 'fluffy bunnies', 26 | }; 27 | }); 28 | 29 | it('should match default closed confirmation modal snapshot', () => { 30 | const tree = renderer 31 | .create(( 32 | 33 | 36 | 37 | )) 38 | .toJSON(); 39 | expect(tree).toMatchSnapshot(); 40 | }); 41 | 42 | it('should match open confirmation modal snapshot', () => { 43 | const tree = renderer 44 | .create(( 45 | 46 | 50 | 51 | )) 52 | .toJSON(); 53 | expect(tree).toMatchSnapshot(); 54 | }); 55 | 56 | it('should match empty password confirmation modal snapshot', () => { 57 | const tree = renderer 58 | .create(( 59 | 60 | 65 | 66 | )) 67 | .toJSON(); 68 | expect(tree).toMatchSnapshot(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/DeleteAccount.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-useless-fragment */ 2 | import React from 'react'; 3 | import renderer from 'react-test-renderer'; 4 | import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; 5 | 6 | // Testing the modals separately, they just clutter up the snapshots if included here. 7 | jest.mock('./ConfirmationModal', () => function ConfirmationModalMock() { 8 | return <>; 9 | }); 10 | jest.mock('./SuccessModal', () => function SuccessModalMock() { 11 | return <>; 12 | }); 13 | 14 | import { DeleteAccount } from './DeleteAccount'; // eslint-disable-line import/first 15 | 16 | const IntlDeleteAccount = injectIntl(DeleteAccount); 17 | 18 | describe('DeleteAccount', () => { 19 | let props = {}; 20 | 21 | beforeEach(() => { 22 | props = { 23 | deleteAccount: jest.fn(), 24 | deleteAccountConfirmation: jest.fn(), 25 | deleteAccountFailure: jest.fn(), 26 | deleteAccountReset: jest.fn(), 27 | deleteAccountCancel: jest.fn(), 28 | status: null, 29 | errorType: null, 30 | hasLinkedTPA: false, 31 | isVerifiedAccount: true, 32 | }; 33 | }); 34 | 35 | it('should match default section snapshot', () => { 36 | const tree = renderer 37 | .create(( 38 | 39 | 42 | 43 | )) 44 | .toJSON(); 45 | 46 | expect(tree).toMatchSnapshot(); 47 | }); 48 | 49 | it('should match unverified account section snapshot', () => { 50 | const tree = renderer 51 | .create(( 52 | 53 | 57 | 58 | )) 59 | .toJSON(); 60 | expect(tree).toMatchSnapshot(); 61 | }); 62 | 63 | it('should match unverified account section snapshot', () => { 64 | const tree = renderer 65 | .create(( 66 | 67 | 71 | 72 | )) 73 | .toJSON(); 74 | expect(tree).toMatchSnapshot(); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/PrintingInstructions.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 3 | import { Hyperlink } from '@openedx/paragon'; 4 | 5 | import { getConfig } from '@edx/frontend-platform'; 6 | import messages from './messages'; 7 | 8 | const PrintingInstructions = (props) => { 9 | const actionLink = ( 10 | 16 | {props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])} 17 | 18 | ); 19 | 20 | // TODO: We lack a good way of providing custom language for a particular site. This is a hack 21 | // to allow edx.org to mention MicroMasters certificates to fulfill its business requirements. 22 | if (getConfig().SITE_NAME === 'edX') { 23 | return ( 24 | 30 | ); 31 | } 32 | 33 | return ( 34 | 40 | ); 41 | }; 42 | 43 | PrintingInstructions.propTypes = { 44 | intl: intlShape.isRequired, 45 | }; 46 | 47 | export default injectIntl(PrintingInstructions); 48 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/SuccessModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | import { ModalLayer, ModalCloseButton } from '@openedx/paragon'; 5 | 6 | import messages from './messages'; 7 | 8 | export const SuccessModal = (props) => { 9 | const { status, intl, onClose } = props; 10 | return ( 11 | 12 | 13 |
    14 |

    15 | {intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])} 16 |

    17 |
    18 |

    19 | {intl.formatMessage(messages['account.settings.delete.account.modal.after.text'])} 20 |

    21 |
    22 |

    23 | {intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])} 24 |

    25 |
    26 | 27 |
    28 | 29 | ); 30 | }; 31 | 32 | SuccessModal.propTypes = { 33 | status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']), 34 | intl: intlShape.isRequired, 35 | onClose: PropTypes.func.isRequired, 36 | }; 37 | 38 | SuccessModal.defaultProps = { 39 | status: null, 40 | }; 41 | 42 | export default injectIntl(SuccessModal); 43 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/SuccessModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; 4 | import { waitFor } from '@testing-library/react'; 5 | import { SuccessModal } from './SuccessModal'; 6 | 7 | // Modal creates a portal. Overriding createPortal allows portals to be tested in jest. 8 | jest.mock('react-dom', () => ({ 9 | ...jest.requireActual('react-dom'), 10 | createPortal: jest.fn(node => node), // Mock portal behavior 11 | })); 12 | 13 | const IntlSuccessModal = injectIntl(SuccessModal); 14 | 15 | describe('SuccessModal', () => { 16 | let props = {}; 17 | 18 | beforeEach(() => { 19 | props = { 20 | onClose: jest.fn(), 21 | status: null, 22 | }; 23 | }); 24 | 25 | it('should match default closed success modal snapshot', async () => { 26 | await waitFor(() => { 27 | const tree = renderer.create(( 28 | )).toJSON(); 29 | expect(tree).toMatchSnapshot(); 30 | }); 31 | await waitFor(() => { 32 | const tree = renderer.create(( 33 | )).toJSON(); 34 | expect(tree).toMatchSnapshot(); 35 | }); 36 | await waitFor(() => { 37 | const tree = renderer.create(( 38 | )).toJSON(); 39 | expect(tree).toMatchSnapshot(); 40 | }); 41 | await waitFor(() => { 42 | const tree = renderer.create(( 43 | )).toJSON(); 44 | expect(tree).toMatchSnapshot(); 45 | }); 46 | }); 47 | 48 | it('should match open success modal snapshot', async () => { 49 | await waitFor(() => { 50 | const tree = renderer.create( 51 | 52 | 56 | , 57 | ).toJSON(); 58 | expect(tree).toMatchSnapshot(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/__snapshots__/BeforeProceedingBanner.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`BeforeProceedingBanner should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link 1`] = ` 4 |
    7 |
    8 | 25 |
    26 |
    27 | Before proceeding, please unlink all social media accounts. 28 |
    29 |
    30 | `; 31 | 32 | exports[`BeforeProceedingBanner should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link 1`] = ` 33 |
    36 |
    37 | 54 |
    55 |
    56 | Before proceeding, please 57 | 62 | unlink all social media accounts 63 | 64 | . 65 |
    66 |
    67 | `; 68 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/__snapshots__/SuccessModal.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SuccessModal should match default closed success modal snapshot 1`] = `null`; 4 | 5 | exports[`SuccessModal should match default closed success modal snapshot 2`] = `null`; 6 | 7 | exports[`SuccessModal should match default closed success modal snapshot 3`] = `null`; 8 | 9 | exports[`SuccessModal should match default closed success modal snapshot 4`] = `null`; 10 | 11 | exports[`SuccessModal should match open success modal snapshot 1`] = ` 12 | [ 13 |
    , 28 |
    37 |
    40 |
    46 |
    49 |

    50 | We're sorry to see you go! Your account will be deleted shortly. 51 |

    52 |
    55 |

    58 | Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email. 59 |

    60 |
    61 |

    62 | 70 |

    71 |
    72 |
    73 |
    , 74 |
    , 89 | ] 90 | `; 91 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/data/actions.js: -------------------------------------------------------------------------------- 1 | import { AsyncActionType } from '../../data/utils'; 2 | 3 | export const DELETE_ACCOUNT = new AsyncActionType('ACCOUNT_SETTINGS', 'DELETE_ACCOUNT'); 4 | DELETE_ACCOUNT.CONFIRMATION = 'ACCOUNT_SETTINGS__DELETE_ACCOUNT__CONFIRMATION'; 5 | DELETE_ACCOUNT.CANCEL = 'ACCOUNT_SETTINGS__DELETE_ACCOUNT__CANCEL'; 6 | 7 | export const deleteAccount = password => ({ 8 | type: DELETE_ACCOUNT.BASE, 9 | payload: { password }, 10 | }); 11 | 12 | export const deleteAccountConfirmation = () => ({ 13 | type: DELETE_ACCOUNT.CONFIRMATION, 14 | }); 15 | 16 | export const deleteAccountBegin = () => ({ 17 | type: DELETE_ACCOUNT.BEGIN, 18 | }); 19 | 20 | export const deleteAccountSuccess = () => ({ 21 | type: DELETE_ACCOUNT.SUCCESS, 22 | }); 23 | 24 | export const deleteAccountFailure = reason => ({ 25 | type: DELETE_ACCOUNT.FAILURE, 26 | payload: { reason }, 27 | }); 28 | 29 | // to clear errors from the confirmation modal 30 | export const deleteAccountReset = () => ({ 31 | type: DELETE_ACCOUNT.RESET, 32 | }); 33 | 34 | // to close the modal 35 | export const deleteAccountCancel = () => ({ 36 | type: DELETE_ACCOUNT.CANCEL, 37 | }); 38 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/data/reducers.js: -------------------------------------------------------------------------------- 1 | import { DELETE_ACCOUNT } from './actions'; 2 | 3 | export const defaultState = { 4 | status: null, 5 | errorType: null, 6 | }; 7 | 8 | const reducer = (state = defaultState, action = null) => { 9 | if (action !== null) { 10 | switch (action.type) { 11 | case DELETE_ACCOUNT.CONFIRMATION: 12 | return { 13 | ...state, 14 | status: 'confirming', 15 | }; 16 | 17 | case DELETE_ACCOUNT.BEGIN: 18 | return { 19 | ...state, 20 | status: 'pending', 21 | }; 22 | 23 | case DELETE_ACCOUNT.SUCCESS: 24 | return { 25 | ...state, 26 | status: 'deleted', 27 | }; 28 | 29 | case DELETE_ACCOUNT.FAILURE: 30 | return { 31 | ...state, 32 | status: 'failed', 33 | errorType: action.payload.reason || 'server', 34 | }; 35 | 36 | case DELETE_ACCOUNT.RESET: { 37 | const oldStatus = state.status; 38 | 39 | return { 40 | ...state, 41 | // clear the error state if applicable, otherwise don't change state 42 | status: oldStatus === 'failed' ? 'confirming' : oldStatus, 43 | errorType: null, 44 | }; 45 | } 46 | 47 | case DELETE_ACCOUNT.CANCEL: 48 | return { 49 | ...state, 50 | status: null, 51 | errorType: null, 52 | }; 53 | 54 | default: 55 | } 56 | } 57 | return state; 58 | }; 59 | 60 | export default reducer; 61 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/data/reducers.test.js: -------------------------------------------------------------------------------- 1 | import reducer from './reducers'; 2 | import { 3 | deleteAccountConfirmation, 4 | deleteAccountBegin, 5 | deleteAccountSuccess, 6 | deleteAccountFailure, 7 | deleteAccountReset, 8 | deleteAccountCancel, 9 | } from './actions'; 10 | 11 | describe('delete-account reducer', () => { 12 | let state = null; 13 | 14 | beforeEach(() => { 15 | state = reducer(); 16 | }); 17 | 18 | it('should process DELETE_ACCOUNT.CONFIRMATION', () => { 19 | const result = reducer(state, deleteAccountConfirmation()); 20 | expect(result).toEqual({ 21 | errorType: null, 22 | status: 'confirming', 23 | }); 24 | }); 25 | 26 | it('should process DELETE_ACCOUNT.BEGIN', () => { 27 | const result = reducer(state, deleteAccountBegin()); 28 | expect(result).toEqual({ 29 | errorType: null, 30 | status: 'pending', 31 | }); 32 | }); 33 | 34 | it('should process DELETE_ACCOUNT.SUCCESS', () => { 35 | const result = reducer(state, deleteAccountSuccess()); 36 | expect(result).toEqual({ 37 | errorType: null, 38 | status: 'deleted', 39 | }); 40 | }); 41 | 42 | it('should process DELETE_ACCOUNT.FAILURE no reason', () => { 43 | const result = reducer(state, deleteAccountFailure()); 44 | expect(result).toEqual({ 45 | errorType: 'server', 46 | status: 'failed', 47 | }); 48 | }); 49 | 50 | it('should process DELETE_ACCOUNT.FAILURE with reason', () => { 51 | const result = reducer(state, deleteAccountFailure('carnivorous buns')); 52 | expect(result).toEqual({ 53 | errorType: 'carnivorous buns', 54 | status: 'failed', 55 | }); 56 | }); 57 | 58 | it('should process DELETE_ACCOUNT.RESET no status', () => { 59 | const result = reducer(state, deleteAccountReset()); 60 | expect(result).toEqual({ 61 | errorType: null, 62 | status: null, 63 | }); 64 | }); 65 | 66 | it('should process DELETE_ACCOUNT.RESET with failed old status', () => { 67 | const result = reducer( 68 | { 69 | errorType: 'carnivorous buns', 70 | status: 'failed', 71 | }, 72 | deleteAccountReset(), 73 | ); 74 | expect(result).toEqual({ 75 | errorType: null, 76 | status: 'confirming', 77 | }); 78 | }); 79 | 80 | it('should process DELETE_ACCOUNT.RESET with pending old status', () => { 81 | const result = reducer( 82 | { 83 | errorType: 'carnivorous buns', 84 | status: 'pending', 85 | }, 86 | deleteAccountReset(), 87 | ); 88 | expect(result).toEqual({ 89 | errorType: null, 90 | status: 'pending', 91 | }); 92 | }); 93 | 94 | it('should process DELETE_ACCOUNT.CANCEL', () => { 95 | const result = reducer( 96 | { 97 | errorType: 'carnivorous buns', 98 | status: 'failed', 99 | }, 100 | deleteAccountCancel(), 101 | ); 102 | expect(result).toEqual({ 103 | errorType: null, 104 | status: null, 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/data/sagas.js: -------------------------------------------------------------------------------- 1 | import { put, call, takeEvery } from 'redux-saga/effects'; 2 | 3 | import { 4 | DELETE_ACCOUNT, 5 | deleteAccountBegin, 6 | deleteAccountSuccess, 7 | deleteAccountFailure, 8 | } from './actions'; 9 | 10 | import { postDeleteAccount } from './service'; 11 | 12 | export function* handleDeleteAccount(action) { 13 | try { 14 | yield put(deleteAccountBegin()); 15 | const response = yield call(postDeleteAccount, action.payload.password); 16 | yield put(deleteAccountSuccess(response)); 17 | } catch (e) { 18 | if (e.response.status === 403) { 19 | yield put(deleteAccountFailure('invalid-password')); 20 | } else if (typeof e.response.data === 'string') { 21 | yield put(deleteAccountFailure()); 22 | } else { 23 | throw e; 24 | } 25 | } 26 | } 27 | 28 | export default function* saga() { 29 | yield takeEvery(DELETE_ACCOUNT.BASE, handleDeleteAccount); 30 | } 31 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/data/service.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | import formurlencoded from 'form-urlencoded'; 4 | import { handleRequestError } from '../../data/utils'; 5 | 6 | /** 7 | * Request deletion of the user's account. 8 | */ 9 | // eslint-disable-next-line import/prefer-default-export 10 | export async function postDeleteAccount(password) { 11 | const { data } = await getAuthenticatedHttpClient() 12 | .post( 13 | `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/deactivate_logout/`, 14 | formurlencoded({ password }), 15 | { 16 | headers: { 17 | 'Content-Type': 'application/x-www-form-urlencoded', 18 | }, 19 | }, 20 | ) 21 | .catch(handleRequestError); 22 | return data; 23 | } 24 | -------------------------------------------------------------------------------- /src/account-settings/delete-account/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './DeleteAccount'; 3 | export { default as reducer } from './data/reducers'; 4 | export { default as saga } from './data/sagas'; 5 | export { DELETE_ACCOUNT } from './data/actions'; 6 | -------------------------------------------------------------------------------- /src/account-settings/hoc.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useLocation, useNavigate } from 'react-router-dom'; 4 | 5 | export const withNavigate = Component => { 6 | const WrappedComponent = props => { 7 | const navigate = useNavigate(); 8 | return ; 9 | }; 10 | return WrappedComponent; 11 | }; 12 | 13 | export const withLocation = Component => { 14 | const WrappedComponent = props => { 15 | const location = useLocation(); 16 | return ; 17 | }; 18 | return WrappedComponent; 19 | }; 20 | -------------------------------------------------------------------------------- /src/account-settings/hoc.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render, screen } from '@testing-library/react'; 4 | import { withLocation, withNavigate } from './hoc'; 5 | 6 | const mockedNavigator = jest.fn(); 7 | 8 | jest.mock('react-router-dom', () => ({ 9 | useNavigate: () => mockedNavigator, 10 | useLocation: () => ({ 11 | pathname: '/current-location', 12 | }), 13 | })); 14 | 15 | // eslint-disable-next-line react/prop-types 16 | const MockComponent = ({ navigate, location }) => ( 17 | // eslint-disable-next-line react/button-has-type, react/prop-types 18 | 19 | ); 20 | const WrappedComponent = withNavigate(withLocation(MockComponent)); 21 | 22 | test('Provide Navigation to Component', () => { 23 | render( 24 | , 25 | ); 26 | const btn = screen.getByTestId('btn'); 27 | fireEvent.click(btn); 28 | 29 | expect(mockedNavigator).toHaveBeenCalledWith('/some-route'); 30 | }); 31 | 32 | test('Provide Location Pathname to Component', () => { 33 | render( 34 | , 35 | ); 36 | 37 | expect(screen.getByTestId('btn').textContent).toContain('/current-location'); 38 | }); 39 | -------------------------------------------------------------------------------- /src/account-settings/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './AccountSettingsPage'; 3 | export { default as reducer } from './data/reducers'; 4 | export { default as saga } from './data/sagas'; 5 | export { storeName } from './data/selectors'; 6 | export { default as NotFoundPage } from './NotFoundPage'; 7 | -------------------------------------------------------------------------------- /src/account-settings/name-change/data/actions.js: -------------------------------------------------------------------------------- 1 | import { AsyncActionType } from '../../data/utils'; 2 | 3 | export const REQUEST_NAME_CHANGE = new AsyncActionType('ACCOUNT_SETTINGS', 'REQUEST_NAME_CHANGE'); 4 | 5 | export const requestNameChange = (username, profileName, verifiedName) => ({ 6 | type: REQUEST_NAME_CHANGE.BASE, 7 | payload: { username, profileName, verifiedName }, 8 | }); 9 | 10 | export const requestNameChangeBegin = () => ({ 11 | type: REQUEST_NAME_CHANGE.BEGIN, 12 | }); 13 | 14 | export const requestNameChangeSuccess = () => ({ 15 | type: REQUEST_NAME_CHANGE.SUCCESS, 16 | }); 17 | 18 | export const requestNameChangeFailure = errors => ({ 19 | type: REQUEST_NAME_CHANGE.FAILURE, 20 | payload: { errors }, 21 | }); 22 | 23 | export const requestNameChangeReset = () => ({ 24 | type: REQUEST_NAME_CHANGE.RESET, 25 | }); 26 | -------------------------------------------------------------------------------- /src/account-settings/name-change/data/reducers.js: -------------------------------------------------------------------------------- 1 | import { REQUEST_NAME_CHANGE } from './actions'; 2 | 3 | export const defaultState = { 4 | saveState: null, 5 | errors: {}, 6 | }; 7 | 8 | const reducer = (state = defaultState, action = null) => { 9 | if (action !== null) { 10 | switch (action.type) { 11 | case REQUEST_NAME_CHANGE.BEGIN: 12 | return { 13 | ...state, 14 | saveState: 'pending', 15 | errors: {}, 16 | }; 17 | 18 | case REQUEST_NAME_CHANGE.SUCCESS: 19 | return { 20 | ...state, 21 | saveState: 'complete', 22 | }; 23 | 24 | case REQUEST_NAME_CHANGE.FAILURE: 25 | return { 26 | ...state, 27 | saveState: 'error', 28 | errors: action.payload.errors || { general_error: 'A technical error occurred. Please try again.' }, 29 | }; 30 | 31 | case REQUEST_NAME_CHANGE.RESET: 32 | return { 33 | ...state, 34 | saveState: null, 35 | errors: {}, 36 | }; 37 | 38 | default: 39 | } 40 | } 41 | return state; 42 | }; 43 | 44 | export default reducer; 45 | -------------------------------------------------------------------------------- /src/account-settings/name-change/data/sagas.js: -------------------------------------------------------------------------------- 1 | import { put, call, takeEvery } from 'redux-saga/effects'; 2 | 3 | import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; 4 | 5 | import { postVerifiedName } from '../../data/service'; 6 | 7 | import { 8 | REQUEST_NAME_CHANGE, 9 | requestNameChangeBegin, 10 | requestNameChangeSuccess, 11 | requestNameChangeFailure, 12 | } from './actions'; 13 | import { postNameChange } from './service'; 14 | 15 | export function* handleRequestNameChange(action) { 16 | let { name: profileName } = getAuthenticatedUser(); 17 | try { 18 | yield put(requestNameChangeBegin()); 19 | if (action.payload.profileName) { 20 | yield call(postNameChange, action.payload.profileName); 21 | profileName = action.payload.profileName; 22 | } 23 | yield call(postVerifiedName, { 24 | username: action.payload.username, 25 | verified_name: action.payload.verifiedName, 26 | profile_name: profileName, 27 | }); 28 | yield put(requestNameChangeSuccess()); 29 | } catch (err) { 30 | if (err.customAttributes?.httpErrorResponseData) { 31 | yield put(requestNameChangeFailure(JSON.parse(err.customAttributes.httpErrorResponseData))); 32 | } else { 33 | yield put(requestNameChangeFailure()); 34 | } 35 | } 36 | } 37 | 38 | export default function* saga() { 39 | yield takeEvery(REQUEST_NAME_CHANGE.BASE, handleRequestNameChange); 40 | } 41 | -------------------------------------------------------------------------------- /src/account-settings/name-change/data/service.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | 4 | import { handleRequestError } from '../../data/utils'; 5 | 6 | // eslint-disable-next-line import/prefer-default-export 7 | export async function postNameChange(name) { 8 | // Requests a pending name change, rather than saving the account name immediately 9 | const requestConfig = { headers: { Accept: 'application/json' } }; 10 | const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/name_change/`; 11 | 12 | const { data } = await getAuthenticatedHttpClient() 13 | .post(requestUrl, { name }, requestConfig) 14 | .catch(error => handleRequestError(error)); 15 | 16 | return data; 17 | } 18 | -------------------------------------------------------------------------------- /src/account-settings/name-change/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './NameChange'; 3 | export { default as reducer } from './data/reducers'; 4 | export { default as saga } from './data/sagas'; 5 | export { REQUEST_NAME_CHANGE } from './data/actions'; 6 | -------------------------------------------------------------------------------- /src/account-settings/name-change/messages.js: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | 'account.settings.name.change.title.id': { 5 | id: 'account.settings.name.change.title.id', 6 | defaultMessage: 'This name change requires identity verification', 7 | description: 'Inform the user that changing their name requires identity verification', 8 | }, 9 | 'account.settings.name.change.title.begin': { 10 | id: 'account.settings.name.change.title.begin', 11 | defaultMessage: 'Before we begin', 12 | description: 'Title before beginning the ID verification process', 13 | }, 14 | 'account.settings.name.change.warning.one': { 15 | id: 'account.settings.name.change.warning.one', 16 | defaultMessage: 'Warning: This action updates the name that appears on all certificates that have been earned on this account in the past and any certificates you are currently earning or will earn in the future.', 17 | description: 'Warning informing the user that a name change will update the name on all of their certificates.', 18 | }, 19 | 'account.settings.name.change.warning.two': { 20 | id: 'account.settings.name.change.warning.two', 21 | defaultMessage: 'This action cannot be undone without verifying your identity.', 22 | description: 'Warning informing the user that a name change cannot be undone without ID verification.', 23 | }, 24 | 'account.settings.name.change.id.name.label': { 25 | id: 'account.settings.name.change.id.name.label', 26 | defaultMessage: 'Enter your name as it appears on your identification card.', 27 | description: 'Form label instructing the user to enter the name on their ID.', 28 | }, 29 | 'account.settings.name.change.id.name.placeholder': { 30 | id: 'account.settings.name.change.id.name.placeholder', 31 | defaultMessage: 'Enter the name on your photo ID', 32 | description: 'Form label instructing the user to enter the name on their ID.', 33 | }, 34 | 'account.settings.name.change.error.valid.name': { 35 | id: 'account.settings.name.change.error.valid.name', 36 | defaultMessage: 'Please enter a valid name.', 37 | description: 'Error that appears when the user doesn’t enter a valid name.', 38 | }, 39 | 'account.settings.name.change.error.general': { 40 | id: 'account.settings.name.change.error.general', 41 | defaultMessage: 'A technical error occurred. Please try again.', 42 | description: 'Generic error message.', 43 | }, 44 | 'account.settings.name.change.continue': { 45 | id: 'account.settings.name.change.continue', 46 | defaultMessage: 'Continue', 47 | description: 'Continue button.', 48 | }, 49 | 'account.settings.name.change.cancel': { 50 | id: 'account.settings.name.change.cancel', 51 | defaultMessage: 'Cancel', 52 | description: 'Cancel button.', 53 | }, 54 | }); 55 | 56 | export default messages; 57 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/ConfirmationAlert.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { getConfig } from '@edx/frontend-platform'; 4 | import { FormattedMessage } from '@edx/frontend-platform/i18n'; 5 | import { Hyperlink } from '@openedx/paragon'; 6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 7 | import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; 8 | 9 | import Alert from '../Alert'; 10 | 11 | const ConfirmationAlert = (props) => { 12 | const { email } = props; 13 | 14 | const technicalSupportLink = ( 15 | 18 | 23 | 24 | ); 25 | 26 | return ( 27 | } 30 | > 31 | 40 | 41 | ); 42 | }; 43 | 44 | ConfirmationAlert.propTypes = { 45 | email: PropTypes.string.isRequired, 46 | }; 47 | 48 | export default ConfirmationAlert; 49 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/RequestInProgressAlert.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage } from '@edx/frontend-platform/i18n'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; 5 | 6 | import Alert from '../Alert'; 7 | 8 | const RequestInProgressAlert = () => ( 9 | } 12 | > 13 | 18 | 19 | ); 20 | 21 | export default RequestInProgressAlert; 22 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/ResetPassword.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; 5 | import { StatefulButton } from '@openedx/paragon'; 6 | 7 | import { resetPassword } from './data/actions'; 8 | import messages from './messages'; 9 | import ConfirmationAlert from './ConfirmationAlert'; 10 | import RequestInProgressAlert from './RequestInProgressAlert'; 11 | 12 | const ResetPassword = (props) => { 13 | const { email, intl, status } = props; 14 | return ( 15 |
    16 |
    17 | 22 |
    23 |

    24 | { 28 | // Swallow clicks if the state is pending. 29 | // We do this instead of disabling the button to prevent 30 | // it from losing focus (disabled elements cannot have focus). 31 | // Disabling it would causes upstream issues in focus management. 32 | // Swallowing the onSubmit event on the form would be better, but 33 | // we would have to add that logic for every field given our 34 | // current structure of the application. 35 | if (status === 'pending') { 36 | e.preventDefault(); 37 | } 38 | props.resetPassword(email); 39 | }} 40 | disabledStates={[]} 41 | labels={{ 42 | default: intl.formatMessage(messages['account.settings.editable.field.password.reset.button']), 43 | }} 44 | /> 45 |

    46 | {status === 'complete' ? : null} 47 | {status === 'forbidden' ? : null} 48 |
    49 | ); 50 | }; 51 | 52 | ResetPassword.propTypes = { 53 | email: PropTypes.string, 54 | intl: intlShape.isRequired, 55 | resetPassword: PropTypes.func.isRequired, 56 | status: PropTypes.string, 57 | }; 58 | 59 | ResetPassword.defaultProps = { 60 | email: '', 61 | status: null, 62 | }; 63 | 64 | const mapStateToProps = state => state.accountSettings.resetPassword; 65 | 66 | export default connect( 67 | mapStateToProps, 68 | { 69 | resetPassword, 70 | }, 71 | )(injectIntl(ResetPassword)); 72 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/data/actions.js: -------------------------------------------------------------------------------- 1 | import { AsyncActionType } from '../../data/utils'; 2 | 3 | export const RESET_PASSWORD = new AsyncActionType('ACCOUNT_SETTINGS', 'RESET_PASSWORD'); 4 | 5 | export const resetPassword = email => ({ 6 | type: RESET_PASSWORD.BASE, 7 | payload: { email }, 8 | }); 9 | 10 | export const resetPasswordBegin = () => ({ 11 | type: RESET_PASSWORD.BEGIN, 12 | }); 13 | 14 | export const resetPasswordSuccess = () => ({ 15 | type: RESET_PASSWORD.SUCCESS, 16 | }); 17 | 18 | export const resetPasswordReset = () => ({ 19 | type: RESET_PASSWORD.RESET, 20 | }); 21 | 22 | export const resetPasswordForbidden = () => ({ 23 | type: RESET_PASSWORD.FORBIDDEN, 24 | }); 25 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/data/reducers.js: -------------------------------------------------------------------------------- 1 | import { RESET_PASSWORD } from './actions'; 2 | 3 | export const defaultState = { 4 | status: null, 5 | }; 6 | 7 | const reducer = (state = defaultState, action = null) => { 8 | if (action !== null) { 9 | switch (action.type) { 10 | case RESET_PASSWORD.BEGIN: 11 | return { 12 | ...state, 13 | status: 'pending', 14 | }; 15 | case RESET_PASSWORD.SUCCESS: 16 | return { 17 | ...state, 18 | status: 'complete', 19 | }; 20 | case RESET_PASSWORD.FORBIDDEN: 21 | return { 22 | ...state, 23 | status: 'forbidden', 24 | }; 25 | 26 | default: 27 | } 28 | } 29 | return state; 30 | }; 31 | 32 | export default reducer; 33 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/data/sagas.js: -------------------------------------------------------------------------------- 1 | import { put, call, takeEvery } from 'redux-saga/effects'; 2 | 3 | import { 4 | resetPasswordBegin, resetPasswordForbidden, resetPasswordSuccess, RESET_PASSWORD, 5 | } from './actions'; 6 | import { postResetPassword } from './service'; 7 | 8 | function* handleResetPassword(action) { 9 | yield put(resetPasswordBegin()); 10 | try { 11 | const response = yield call(postResetPassword, action.payload.email); 12 | yield put(resetPasswordSuccess(response)); 13 | } catch (error) { 14 | if (error.response && error.response.status === 403) { 15 | yield put(resetPasswordForbidden(error)); 16 | } else { 17 | throw error; 18 | } 19 | } 20 | } 21 | 22 | export default function* saga() { 23 | yield takeEvery(RESET_PASSWORD.BASE, handleResetPassword); 24 | } 25 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/data/service.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | import formurlencoded from 'form-urlencoded'; 4 | import { handleRequestError } from '../../data/utils'; 5 | 6 | // eslint-disable-next-line import/prefer-default-export 7 | export async function postResetPassword(email) { 8 | const { data } = await getAuthenticatedHttpClient() 9 | .post( 10 | `${getConfig().LMS_BASE_URL}/password_reset/`, 11 | formurlencoded({ email }), 12 | { 13 | headers: { 14 | 'Content-Type': 'application/x-www-form-urlencoded', 15 | }, 16 | }, 17 | ) 18 | .catch(handleRequestError); 19 | 20 | return data; 21 | } 22 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './ResetPassword'; 3 | export { default as reducer } from './data/reducers'; 4 | export { RESET_PASSWORD } from './data/actions'; 5 | export { default as saga } from './data/sagas'; 6 | -------------------------------------------------------------------------------- /src/account-settings/reset-password/messages.js: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | 'account.settings.editable.field.password.reset.button': { 5 | id: 'account.settings.editable.field.password.reset.button', 6 | defaultMessage: 'Reset Password', 7 | description: 'The password reset button in account settings', 8 | }, 9 | }); 10 | 11 | export default messages; 12 | -------------------------------------------------------------------------------- /src/account-settings/site-language/actions.js: -------------------------------------------------------------------------------- 1 | import { AsyncActionType } from '../data/utils'; 2 | 3 | export const FETCH_SITE_LANGUAGES = new AsyncActionType('SITE_LANGUAGE', 'FETCH_SITE_LANGUAGES'); 4 | 5 | export const fetchSiteLanguages = handleNavigation => ({ 6 | type: FETCH_SITE_LANGUAGES.BASE, 7 | payload: { handleNavigation }, 8 | }); 9 | 10 | export const fetchSiteLanguagesBegin = () => ({ 11 | type: FETCH_SITE_LANGUAGES.BEGIN, 12 | }); 13 | 14 | export const fetchSiteLanguagesSuccess = siteLanguageList => ({ 15 | type: FETCH_SITE_LANGUAGES.SUCCESS, 16 | payload: { siteLanguageList }, 17 | }); 18 | 19 | export const fetchSiteLanguagesFailure = error => ({ 20 | type: FETCH_SITE_LANGUAGES.FAILURE, 21 | payload: { error }, 22 | }); 23 | 24 | export const fetchSiteLanguagesReset = () => ({ 25 | type: FETCH_SITE_LANGUAGES.RESET, 26 | }); 27 | -------------------------------------------------------------------------------- /src/account-settings/site-language/constants.js: -------------------------------------------------------------------------------- 1 | const siteLanguageList = [ 2 | { 3 | code: 'en', 4 | name: 'English', 5 | released: true, 6 | }, 7 | { 8 | code: 'ar', 9 | name: 'العربية', 10 | released: true, 11 | }, 12 | { 13 | code: 'ca', 14 | name: 'Català', 15 | released: false, 16 | }, 17 | { 18 | code: 'es-419', 19 | name: 'Español (Latinoamérica)', 20 | released: true, 21 | }, 22 | { 23 | code: 'fa-ir', 24 | name: 'فارسی', 25 | released: true, 26 | }, 27 | { 28 | code: 'fr', 29 | name: 'Français', 30 | released: true, 31 | }, 32 | { 33 | code: 'he', 34 | name: 'עברית', 35 | released: false, 36 | }, 37 | { 38 | code: 'id', 39 | name: 'Bahasa Indonesia', 40 | released: false, 41 | }, 42 | { 43 | code: 'ko-kr', 44 | name: '한국어 (대한민국)', 45 | released: false, 46 | }, 47 | { 48 | code: 'pl', 49 | name: 'Polski', 50 | released: false, 51 | }, 52 | { 53 | code: 'pt-br', 54 | name: 'Português (Brasil)', 55 | released: false, 56 | }, 57 | { 58 | code: 'ru', 59 | name: 'Русский', 60 | released: false, 61 | }, 62 | { 63 | code: 'th', 64 | name: 'ไทย', 65 | released: false, 66 | }, 67 | { 68 | code: 'uk', 69 | name: 'Українська', 70 | released: false, 71 | }, 72 | { 73 | code: 'zh-cn', 74 | name: '中文 (简体)', 75 | released: true, 76 | }, 77 | { 78 | code: 'pt-pt', 79 | name: 'Português', 80 | released: true, 81 | }, 82 | { 83 | code: 'it-it', 84 | name: 'Italian', 85 | released: true, 86 | }, 87 | { 88 | code: 'de-de', 89 | name: 'German', 90 | released: true, 91 | }, 92 | { 93 | code: 'hi', 94 | name: 'Hindi', 95 | released: true, 96 | }, 97 | { 98 | code: 'fr-ca', 99 | name: 'French (CA)', 100 | released: true, 101 | }, 102 | { 103 | code: 'te', 104 | name: 'తెలుగు', 105 | released: true, 106 | }, 107 | { 108 | code: 'da', 109 | name: 'dansk', 110 | released: true, 111 | }, 112 | { 113 | code: 'el', 114 | name: 'Ελληνικά', 115 | released: true, 116 | }, 117 | { 118 | code: 'es-es', 119 | name: 'Español (España)', 120 | released: true, 121 | }, 122 | { 123 | code: 'sw', 124 | name: 'Kiswahili', 125 | released: true, 126 | }, 127 | { 128 | code: 'tr-tr', 129 | name: 'Türkçe (Türkiye)', 130 | released: true, 131 | }, 132 | ]; 133 | 134 | export default siteLanguageList; 135 | -------------------------------------------------------------------------------- /src/account-settings/site-language/index.js: -------------------------------------------------------------------------------- 1 | export { default as reducer } from './reducers'; 2 | export { default as saga } from './sagas'; 3 | export { 4 | getSiteLanguageList, 5 | patchPreferences, 6 | postSetLang, 7 | } from './service'; 8 | export { siteLanguageOptionsSelector, siteLanguageListSelector } from './selectors'; 9 | export { fetchSiteLanguages, FETCH_SITE_LANGUAGES } from './actions'; 10 | -------------------------------------------------------------------------------- /src/account-settings/site-language/reducers.js: -------------------------------------------------------------------------------- 1 | import { FETCH_SITE_LANGUAGES } from './actions'; 2 | 3 | export const defaultState = { 4 | loading: false, 5 | loaded: false, 6 | loadingError: null, 7 | siteLanguageList: [], 8 | }; 9 | 10 | const reducer = (state = defaultState, action = null) => { 11 | if (action !== null) { 12 | switch (action.type) { 13 | case FETCH_SITE_LANGUAGES.BEGIN: 14 | return { 15 | ...state, 16 | loading: true, 17 | loaded: false, 18 | loadingError: null, 19 | }; 20 | case FETCH_SITE_LANGUAGES.SUCCESS: 21 | return { 22 | ...state, 23 | siteLanguageList: action.payload.siteLanguageList, 24 | loading: false, 25 | loaded: true, 26 | loadingError: null, 27 | }; 28 | case FETCH_SITE_LANGUAGES.FAILURE: 29 | return { 30 | ...state, 31 | loading: false, 32 | loaded: false, 33 | loadingError: action.payload.error, 34 | }; 35 | case FETCH_SITE_LANGUAGES.RESET: 36 | return { 37 | ...state, 38 | loading: false, 39 | loaded: false, 40 | loadingError: null, 41 | }; 42 | default: 43 | } 44 | } 45 | return state; 46 | }; 47 | 48 | export default reducer; 49 | -------------------------------------------------------------------------------- /src/account-settings/site-language/sagas.js: -------------------------------------------------------------------------------- 1 | import { call, put, takeEvery } from 'redux-saga/effects'; 2 | 3 | import { 4 | fetchSiteLanguagesBegin, 5 | fetchSiteLanguagesSuccess, 6 | fetchSiteLanguagesFailure, 7 | FETCH_SITE_LANGUAGES, 8 | } from './actions'; 9 | 10 | import { getSiteLanguageList } from './service'; 11 | import { handleFailure } from '../data/utils'; 12 | 13 | function* handleFetchSiteLanguages(action) { 14 | try { 15 | yield put(fetchSiteLanguagesBegin()); 16 | const siteLanguageList = yield call(getSiteLanguageList); 17 | yield put(fetchSiteLanguagesSuccess(siteLanguageList)); 18 | } catch (e) { 19 | yield call(handleFailure, e, action.payload.handleNavigation, fetchSiteLanguagesFailure); 20 | } 21 | } 22 | 23 | export default function* saga() { 24 | yield takeEvery(FETCH_SITE_LANGUAGES.BASE, handleFetchSiteLanguages); 25 | } 26 | -------------------------------------------------------------------------------- /src/account-settings/site-language/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getModuleState } from '../data/utils'; 3 | 4 | export const storePath = ['accountSettings', 'siteLanguage']; 5 | 6 | const siteLanguageSelector = state => getModuleState(state, storePath); 7 | 8 | export const siteLanguageListSelector = createSelector( 9 | siteLanguageSelector, 10 | siteLanguage => siteLanguage.siteLanguageList, 11 | ); 12 | 13 | export const siteLanguageOptionsSelector = createSelector( 14 | siteLanguageSelector, 15 | siteLanguage => siteLanguage.siteLanguageList.map(({ code, name }) => ({ 16 | value: code, 17 | label: name, 18 | })), 19 | ); 20 | -------------------------------------------------------------------------------- /src/account-settings/site-language/service.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | import { convertKeyNames, snakeCaseObject } from '@edx/frontend-platform/utils'; 4 | import siteLanguageList from './constants'; 5 | 6 | export async function getSiteLanguageList() { 7 | return siteLanguageList; 8 | } 9 | 10 | export async function patchPreferences(username, params) { 11 | let processedParams = snakeCaseObject(params); 12 | processedParams = convertKeyNames(processedParams, { 13 | pref_lang: 'pref-lang', 14 | }); 15 | 16 | await getAuthenticatedHttpClient() 17 | .patch(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`, processedParams, { 18 | headers: { 'Content-Type': 'application/merge-patch+json' }, 19 | }); 20 | 21 | return params; // TODO: Once the server returns the updated preferences object, return that. 22 | } 23 | 24 | export async function postSetLang(code) { 25 | const formData = new FormData(); 26 | const requestConfig = { 27 | headers: { 28 | Accept: 'application/json', 29 | 'X-Requested-With': 'XMLHttpRequest', 30 | }, 31 | }; 32 | const url = `${getConfig().LMS_BASE_URL}/i18n/setlang/`; 33 | formData.append('language', code); 34 | 35 | await getAuthenticatedHttpClient() 36 | .post(url, formData, requestConfig); 37 | } 38 | -------------------------------------------------------------------------------- /src/account-settings/test/JumpNav.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; 4 | import { AppProvider } from '@edx/frontend-platform/react'; 5 | import { initializeMockApp, mergeConfig, setConfig } from '@edx/frontend-platform'; 6 | 7 | import JumpNav from '../JumpNav'; 8 | import configureStore from '../../data/configureStore'; 9 | 10 | const IntlJumpNav = injectIntl(JumpNav); 11 | 12 | describe('JumpNav', () => { 13 | mergeConfig({ 14 | ENABLE_ACCOUNT_DELETION: true, 15 | }); 16 | 17 | let props = {}; 18 | let store; 19 | 20 | beforeEach(() => { 21 | initializeMockApp({ 22 | authenticatedUser: { 23 | userId: 3, 24 | username: 'abc123', 25 | administrator: true, 26 | roles: [], 27 | }, 28 | }); 29 | 30 | props = { 31 | intl: {}, 32 | }; 33 | store = configureStore({ 34 | notificationPreferences: { 35 | showPreferences: false, 36 | }, 37 | }); 38 | }); 39 | 40 | it('should not render delete account link', async () => { 41 | setConfig({ 42 | ENABLE_ACCOUNT_DELETION: false, 43 | }); 44 | 45 | render( 46 | 47 | 48 | 49 | 50 | , 51 | ); 52 | 53 | expect(await screen.queryByText('Delete My Account')).toBeNull(); 54 | }); 55 | 56 | it('should render delete account link', async () => { 57 | setConfig({ 58 | ENABLE_ACCOUNT_DELETION: true, 59 | }); 60 | 61 | props = { 62 | ...props, 63 | }; 64 | 65 | render( 66 | 67 | 68 | 69 | 70 | , 71 | ); 72 | 73 | expect(await screen.findByText('Delete My Account')).toBeVisible(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/account-settings/test/mockData.js: -------------------------------------------------------------------------------- 1 | const mockData = { 2 | accountSettings: { 3 | loading: false, 4 | loaded: true, 5 | loadingError: null, 6 | data: null, 7 | values: { 8 | username: 'test_username', 9 | country: 'AD', 10 | accomplishments_shared: false, 11 | name: 'test_name', 12 | email: 'test_email@test.com', 13 | id: 533, 14 | verified_name: null, 15 | extended_profile: [ 16 | { 17 | field_name: 'work_experience', 18 | field_value: '', 19 | }, 20 | ], 21 | gender: null, 22 | 'pref-lang': 'en', 23 | }, 24 | errors: {}, 25 | confirmationValues: {}, 26 | drafts: {}, 27 | saveState: null, 28 | timeZones: [ 29 | { 30 | time_zone: 'Africa/Abidjan', 31 | description: 'Africa/Abidjan (GMT, UTC+0000)', 32 | }, 33 | ], 34 | countryTimeZones: [ 35 | { 36 | time_zone: 'Europe/Andorra', 37 | description: 'Europe/Andorra (CET, UTC+0100)', 38 | }, 39 | ], 40 | previousSiteLanguage: null, 41 | deleteAccount: { 42 | status: null, 43 | errorType: null, 44 | }, 45 | siteLanguage: { 46 | loading: false, 47 | loaded: true, 48 | loadingError: null, 49 | siteLanguageList: [ 50 | { 51 | code: 'en', 52 | name: 'English', 53 | released: true, 54 | }, 55 | ], 56 | }, 57 | resetPassword: { 58 | status: null, 59 | }, 60 | nameChange: { 61 | saveState: null, 62 | errors: {}, 63 | }, 64 | thirdPartyAuth: { 65 | providers: [ 66 | { 67 | id: 'oa2-google-oauth2', 68 | name: 'Google', 69 | connected: false, 70 | accepts_logins: true, 71 | connectUrl: 'http://localhost:18000/auth/login/google-oauth2/?auth_entry=account_settings&next=%2Faccount%2Fsettings', 72 | disconnectUrl: 'http://localhost:18000/auth/disconnect/google-oauth2/?', 73 | }, 74 | ], 75 | disconnectionStatuses: {}, 76 | errors: {}, 77 | }, 78 | verifiedName: null, 79 | mostRecentVerifiedName: {}, 80 | verifiedNameHistory: { 81 | use_verified_name_for_certs: false, 82 | results: [], 83 | }, 84 | profileDataManager: null, 85 | }, 86 | notificationPreferences: { 87 | showPreferences: true, 88 | courses: { 89 | status: 'success', 90 | courses: [], 91 | pagination: { 92 | count: 0, 93 | currentPage: 1, 94 | hasMore: false, 95 | totalPages: 1, 96 | }, 97 | }, 98 | preferences: { 99 | status: 'idle', 100 | updatePreferenceStatus: 'idle', 101 | selectedCourse: 'account', 102 | preferences: [], 103 | apps: [], 104 | nonEditable: {}, 105 | }, 106 | }, 107 | }; 108 | 109 | export default mockData; 110 | -------------------------------------------------------------------------------- /src/account-settings/third-party-auth/data/actions.js: -------------------------------------------------------------------------------- 1 | import { AsyncActionType } from '../../data/utils'; 2 | 3 | export const DISCONNECT_AUTH = new AsyncActionType('ACCOUNT_SETTINGS', 'DISCONNECT_AUTH'); 4 | 5 | export const disconnectAuth = (url, providerId) => ({ 6 | type: DISCONNECT_AUTH.BASE, payload: { url, providerId }, 7 | }); 8 | export const disconnectAuthBegin = providerId => ({ 9 | type: DISCONNECT_AUTH.BEGIN, payload: { providerId }, 10 | }); 11 | export const disconnectAuthSuccess = (providerId, thirdPartyAuthProviders) => ({ 12 | type: DISCONNECT_AUTH.SUCCESS, 13 | payload: { providerId, thirdPartyAuthProviders }, 14 | }); 15 | export const disconnectAuthFailure = providerId => ({ 16 | type: DISCONNECT_AUTH.FAILURE, payload: { providerId }, 17 | }); 18 | export const disconnectAuthReset = providerId => ({ 19 | type: DISCONNECT_AUTH.RESET, payload: { providerId }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/account-settings/third-party-auth/data/reducers.js: -------------------------------------------------------------------------------- 1 | import { DISCONNECT_AUTH } from './actions'; 2 | 3 | export const defaultState = { 4 | providers: [], 5 | disconnectionStatuses: {}, 6 | errors: {}, 7 | }; 8 | 9 | const reducer = (state = defaultState, action = null) => { 10 | if (action !== null) { 11 | switch (action.type) { 12 | case DISCONNECT_AUTH.BEGIN: 13 | return { 14 | ...state, 15 | disconnectionStatuses: { 16 | ...state.disconnectionStatuses, 17 | [action.payload.providerId]: 'pending', 18 | }, 19 | }; 20 | case DISCONNECT_AUTH.SUCCESS: 21 | return { 22 | ...state, 23 | disconnectionStatuses: { 24 | ...state.disconnectionStatuses, 25 | [action.payload.providerId]: 'complete', 26 | }, 27 | providers: action.payload.thirdPartyAuthProviders, 28 | }; 29 | case DISCONNECT_AUTH.FAILURE: 30 | return { 31 | ...state, 32 | disconnectionStatuses: { 33 | ...state.disconnectionStatuses, 34 | [action.payload.providerId]: 'error', 35 | }, 36 | errors: { 37 | ...state.errors, 38 | [action.payload.providerId]: true, 39 | }, 40 | }; 41 | case DISCONNECT_AUTH.RESET: 42 | return { 43 | ...state, 44 | disconnectionStatuses: { 45 | ...state.disconnectionStatuses, 46 | [action.payload.providerId]: null, 47 | }, 48 | errors: { 49 | ...state.errors, 50 | [action.payload.providerId]: null, 51 | }, 52 | }; 53 | default: 54 | } 55 | } 56 | return state; 57 | }; 58 | 59 | export default reducer; 60 | -------------------------------------------------------------------------------- /src/account-settings/third-party-auth/data/sagas.js: -------------------------------------------------------------------------------- 1 | import { call, put, takeEvery } from 'redux-saga/effects'; 2 | import { logError } from '@edx/frontend-platform/logging'; 3 | 4 | import { 5 | disconnectAuthReset, 6 | disconnectAuthBegin, 7 | disconnectAuthSuccess, 8 | disconnectAuthFailure, 9 | DISCONNECT_AUTH, 10 | } from './actions'; 11 | 12 | import { 13 | getThirdPartyAuthProviders, 14 | postDisconnectAuth, 15 | } from './service'; 16 | 17 | function* handleDisconnectAuth(action) { 18 | const { providerId } = action.payload; 19 | try { 20 | yield put(disconnectAuthReset(providerId)); 21 | yield put(disconnectAuthBegin(providerId)); 22 | yield call(postDisconnectAuth, action.payload.url); 23 | const thirdPartyAuthProviders = yield call(getThirdPartyAuthProviders); 24 | yield put(disconnectAuthSuccess(providerId, thirdPartyAuthProviders)); 25 | } catch (e) { 26 | logError(e); 27 | yield put(disconnectAuthFailure(providerId)); 28 | } 29 | } 30 | 31 | export default function* saga() { 32 | yield takeEvery(DISCONNECT_AUTH.BASE, handleDisconnectAuth); 33 | } 34 | -------------------------------------------------------------------------------- /src/account-settings/third-party-auth/data/service.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | 4 | import { handleRequestError } from '../../data/utils'; 5 | 6 | export async function getThirdPartyAuthProviders() { 7 | const { data } = await getAuthenticatedHttpClient() 8 | .get(`${getConfig().LMS_BASE_URL}/api/third_party_auth/v0/providers/user_status`) 9 | .catch(handleRequestError); 10 | 11 | return data.map(({ connect_url: connectUrl, disconnect_url: disconnectUrl, ...provider }) => ({ 12 | ...provider, 13 | connectUrl: `${getConfig().LMS_BASE_URL}${connectUrl}`, 14 | disconnectUrl: `${getConfig().LMS_BASE_URL}${disconnectUrl}`, 15 | })); 16 | } 17 | 18 | export async function postDisconnectAuth(url) { 19 | const requestConfig = { headers: { Accept: 'application/json' } }; 20 | const { data } = await getAuthenticatedHttpClient() 21 | .post(url, {}, requestConfig) 22 | .catch(handleRequestError); 23 | return data; 24 | } 25 | -------------------------------------------------------------------------------- /src/account-settings/third-party-auth/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './ThirdPartyAuth'; 3 | export { default as reducer } from './data/reducers'; 4 | export { default as saga } from './data/sagas'; 5 | export { getThirdPartyAuthProviders, postDisconnectAuth } from './data/service'; 6 | export { DISCONNECT_AUTH } from './data/actions'; 7 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const IDLE_STATUS = 'idle'; 2 | export const LOADING_STATUS = 'loading'; 3 | export const SUCCESS_STATUS = 'success'; 4 | export const FAILURE_STATUS = 'failure'; 5 | -------------------------------------------------------------------------------- /src/data/configureStore.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | import { applyMiddleware, createStore, compose } from 'redux'; 3 | import thunkMiddleware from 'redux-thunk'; 4 | import { composeWithDevTools } from 'redux-devtools-extension'; 5 | import { createLogger } from 'redux-logger'; 6 | import createSagaMiddleware from 'redux-saga'; 7 | 8 | import createRootReducer from './reducers'; 9 | import rootSaga from './sagas'; 10 | 11 | const sagaMiddleware = createSagaMiddleware(); 12 | 13 | function composeMiddleware() { 14 | if (getConfig().ENVIRONMENT === 'development') { 15 | const loggerMiddleware = createLogger({ 16 | collapsed: true, 17 | }); 18 | return composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware)); 19 | } 20 | 21 | return compose(applyMiddleware(thunkMiddleware, sagaMiddleware)); 22 | } 23 | 24 | export default function configureStore(initialState = {}) { 25 | const store = createStore( 26 | createRootReducer(), 27 | initialState, 28 | composeMiddleware(), 29 | ); 30 | sagaMiddleware.run(rootSaga); 31 | 32 | return store; 33 | } 34 | -------------------------------------------------------------------------------- /src/data/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import { 4 | reducer as accountSettingsReducer, 5 | storeName as accountSettingsStoreName, 6 | } from '../account-settings'; 7 | import notificationPreferencesReducer from '../notification-preferences/data/reducers'; 8 | 9 | const createRootReducer = () => combineReducers({ 10 | [accountSettingsStoreName]: accountSettingsReducer, 11 | notificationPreferences: notificationPreferencesReducer, 12 | }); 13 | export default createRootReducer; 14 | -------------------------------------------------------------------------------- /src/data/sagas.js: -------------------------------------------------------------------------------- 1 | import { all } from 'redux-saga/effects'; 2 | import { saga as accountSettingsSaga } from '../account-settings'; 3 | 4 | export default function* rootSaga() { 5 | yield all([accountSettingsSaga()]); 6 | } 7 | -------------------------------------------------------------------------------- /src/divider/Divider.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import classNames from 'classnames'; 3 | 4 | const Divider = ({ className, ...props }) => ( 5 |
    6 | ); 7 | 8 | Divider.propTypes = { 9 | className: PropTypes.string, 10 | }; 11 | 12 | Divider.defaultProps = { 13 | className: undefined, 14 | }; 15 | 16 | export default Divider; 17 | -------------------------------------------------------------------------------- /src/divider/index.jsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export { default as Divider } from './Divider'; 3 | -------------------------------------------------------------------------------- /src/head/Head.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | import { getConfig } from '@edx/frontend-platform'; 5 | 6 | import messages from './messages'; 7 | 8 | const Head = ({ intl }) => ( 9 | 10 | 11 | {intl.formatMessage(messages['account.page.title'], { siteName: getConfig().SITE_NAME })} 12 | 13 | 14 | 15 | ); 16 | 17 | Head.propTypes = { 18 | intl: intlShape.isRequired, 19 | }; 20 | 21 | export default injectIntl(Head); 22 | -------------------------------------------------------------------------------- /src/head/Head.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IntlProvider } from '@edx/frontend-platform/i18n'; 3 | import { Helmet } from 'react-helmet'; 4 | import { render } from '@testing-library/react'; 5 | import { getConfig } from '@edx/frontend-platform'; 6 | import Head from './Head'; 7 | 8 | describe('Head', () => { 9 | const props = {}; 10 | it('should match render title tag and fivicon with the site configuration values', () => { 11 | render(); 12 | const helmet = Helmet.peek(); 13 | expect(helmet.title).toEqual(`Account | ${getConfig().SITE_NAME}`); 14 | expect(helmet.linkTags[0].rel).toEqual('shortcut icon'); 15 | expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/head/messages.js: -------------------------------------------------------------------------------- 1 | import { defineMessages } from '@edx/frontend-platform/i18n'; 2 | 3 | const messages = defineMessages({ 4 | 'account.page.title': { 5 | id: 'account.page.title', 6 | defaultMessage: 'Account | {siteName}', 7 | description: 'Title tag', 8 | }, 9 | }); 10 | 11 | export default messages; 12 | -------------------------------------------------------------------------------- /src/hooks.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { getConfig } from '@edx/frontend-platform'; 4 | import { logError } from '@edx/frontend-platform/logging'; 5 | import { breakpoints, useWindowSize } from '@openedx/paragon'; 6 | 7 | import { 8 | IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS, FAILURE_STATUS, 9 | } from './constants'; 10 | 11 | // eslint-disable-next-line import/prefer-default-export 12 | export function useAsyncCall(asyncFunc) { 13 | // React doesn't batch setStates call in async useEffect hooks, 14 | // so we use a combined object here to ensure that users 15 | // re-render once. 16 | const [data, setData] = useState({ status: IDLE_STATUS }); 17 | 18 | useEffect( 19 | () => { 20 | (async () => { 21 | setData(currData => ({ ...currData, status: LOADING_STATUS })); 22 | const response = await asyncFunc(); 23 | 24 | if (Object.keys(response).length === 0) { 25 | setData(currData => ({ ...currData, status: FAILURE_STATUS, data: response })); 26 | } else { 27 | setData(currData => ({ ...currData, status: SUCCESS_STATUS, data: response })); 28 | } 29 | })(); 30 | }, 31 | [asyncFunc], 32 | ); 33 | 34 | return data; 35 | } 36 | 37 | // Redirect the user to their original location based on session storage 38 | export function useRedirect() { 39 | const [redirect, setRedirect] = useState({ 40 | location: 'dashboard', 41 | text: 'id.verification.return.dashboard', 42 | }); 43 | 44 | useEffect(() => { 45 | if (sessionStorage.getItem('courseId')) { 46 | setRedirect({ 47 | location: `courses/${sessionStorage.getItem('courseId')}`, 48 | text: 'id.verification.return.course', 49 | }); 50 | } else if (sessionStorage.getItem('next')) { 51 | setRedirect({ 52 | location: sessionStorage.getItem('next'), 53 | text: 'id.verification.return.generic', 54 | }); 55 | } 56 | }, []); 57 | 58 | return redirect; 59 | } 60 | 61 | export function useFeedbackWrapper() { 62 | useEffect(() => { 63 | try { 64 | // eslint-disable-next-line no-undef 65 | window.usabilla_live = lightningjs?.require('usabilla_live', getConfig().LEARNER_FEEDBACK_URL); 66 | } catch (error) { 67 | logError('Error loading usabilla_live', error); 68 | } 69 | }, []); 70 | } 71 | 72 | export function useIsOnMobile() { 73 | const windowSize = useWindowSize(); 74 | return windowSize.width <= breakpoints.small.minWidth; 75 | } 76 | -------------------------------------------------------------------------------- /src/i18n/index.js: -------------------------------------------------------------------------------- 1 | export default []; 2 | -------------------------------------------------------------------------------- /src/id-verification/AccessBlocked.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { getConfig } from '@edx/frontend-platform'; 4 | import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; 5 | 6 | import messages from './IdVerification.messages'; 7 | import { ERROR_REASONS } from './IdVerificationContext'; 8 | 9 | const AccessBlocked = ({ error, intl }) => { 10 | const handleMessage = () => { 11 | if (error === ERROR_REASONS.COURSE_ENROLLMENT) { 12 | return

    {intl.formatMessage(messages['id.verification.access.blocked.enrollment'])}

    ; 13 | } 14 | if (error === ERROR_REASONS.EXISTING_REQUEST) { 15 | return

    {intl.formatMessage(messages['id.verification.access.blocked.pending'])}

    ; 16 | } 17 | return ( 18 | no-reply@registration.edx.org, 24 | }} 25 | /> 26 | ); 27 | }; 28 | 29 | return ( 30 |
    31 |

    32 | {intl.formatMessage(messages['id.verification.access.blocked.title'])} 33 |

    34 | {handleMessage()} 35 | 40 |
    41 | ); 42 | }; 43 | 44 | AccessBlocked.propTypes = { 45 | intl: intlShape.isRequired, 46 | error: PropTypes.string.isRequired, 47 | }; 48 | 49 | export default injectIntl(AccessBlocked); 50 | -------------------------------------------------------------------------------- /src/id-verification/CameraHelp.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Collapsible } from '@openedx/paragon'; 4 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 5 | 6 | import { getConfig } from '@edx/frontend-platform'; 7 | import messages from './IdVerification.messages'; 8 | 9 | const CameraHelp = (props) => ( 10 |
    11 | 17 |

    18 | {props.intl.formatMessage(messages[`id.verification.camera.help.sight.answer.${props.isPortrait ? 'portrait' : 'id'}`])} 19 |

    20 |
    21 | 27 |

    28 | {props.intl.formatMessage( 29 | messages['id.verification.camera.help.difficulty.answer'], 30 | { siteName: getConfig().SITE_NAME }, 31 | )} 32 |

    33 |
    34 |
    35 | ); 36 | 37 | CameraHelp.propTypes = { 38 | intl: intlShape.isRequired, 39 | isOpen: PropTypes.bool, 40 | isPortrait: PropTypes.bool, 41 | }; 42 | 43 | CameraHelp.defaultProps = { 44 | isOpen: false, 45 | isPortrait: false, 46 | }; 47 | 48 | export default injectIntl(CameraHelp); 49 | -------------------------------------------------------------------------------- /src/id-verification/CameraHelpWithUpload.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Collapsible } from '@openedx/paragon'; 4 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 5 | import { sendTrackEvent } from '@edx/frontend-platform/analytics'; 6 | 7 | import messages from './IdVerification.messages'; 8 | import ImageFileUpload from './ImageFileUpload'; 9 | import IdVerificationContext from './IdVerificationContext'; 10 | import ImagePreview from './ImagePreview'; 11 | import SupportedMediaTypes from './SupportedMediaTypes'; 12 | 13 | const CameraHelpWithUpload = (props) => { 14 | const { setIdPhotoFile, idPhotoFile, userId } = useContext(IdVerificationContext); 15 | const [hasUploadedImage, setHasUploadedImage] = useState(false); 16 | 17 | const setAndTrackIdPhotoFile = (image) => { 18 | sendTrackEvent('edx.id_verification.upload_id', { 19 | category: 'id_verification', 20 | user_id: userId, 21 | }); 22 | setHasUploadedImage(true); 23 | setIdPhotoFile(image); 24 | }; 25 | 26 | return ( 27 |
    28 | 35 | {idPhotoFile && hasUploadedImage && } 36 |

    37 | {props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])} 38 | 39 |

    40 | 41 |
    42 |
    43 | ); 44 | }; 45 | 46 | CameraHelpWithUpload.propTypes = { 47 | intl: intlShape.isRequired, 48 | isOpen: PropTypes.bool, 49 | }; 50 | 51 | CameraHelpWithUpload.defaultProps = { 52 | isOpen: false, 53 | }; 54 | 55 | export default injectIntl(CameraHelpWithUpload); 56 | -------------------------------------------------------------------------------- /src/id-verification/CollapsibleImageHelp.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { sendTrackEvent } from '@edx/frontend-platform/analytics'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | import { Button, Collapsible } from '@openedx/paragon'; 5 | 6 | import IdVerificationContext from './IdVerificationContext'; 7 | import messages from './IdVerification.messages'; 8 | 9 | const CollapsibleImageHelp = (props) => { 10 | const { 11 | userId, useCameraForId, setUseCameraForId, 12 | } = useContext(IdVerificationContext); 13 | 14 | const handleClick = () => { 15 | const toggleTo = useCameraForId ? 'upload' : 'camera'; 16 | const eventName = `edx.id_verification.toggle_to.${toggleTo}`; 17 | sendTrackEvent(eventName, { 18 | category: 'id_verification', 19 | user_id: userId, 20 | }); 21 | setUseCameraForId(!useCameraForId); 22 | }; 23 | 24 | return ( 25 | 33 |

    34 | {useCameraForId 35 | ? props.intl.formatMessage(messages['id.verification.photo.upload.help.text']) 36 | : props.intl.formatMessage(messages['id.verification.photo.camera.help.text'])} 37 |

    38 | 48 |
    49 | ); 50 | }; 51 | 52 | CollapsibleImageHelp.propTypes = { 53 | intl: intlShape.isRequired, 54 | }; 55 | 56 | export default injectIntl(CollapsibleImageHelp); 57 | -------------------------------------------------------------------------------- /src/id-verification/IdVerificationContext.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IdVerificationContext = React.createContext({}); 4 | 5 | const MEDIA_ACCESS = { 6 | PENDING: 'pending', 7 | UNSUPPORTED: 'unsupported', 8 | DENIED: 'denied', 9 | GRANTED: 'granted', 10 | }; 11 | 12 | const ERROR_REASONS = { 13 | COURSE_ENROLLMENT: 'course_enrollment', 14 | EXISTING_REQUEST: 'existing_request', 15 | CANNOT_VERIFY: 'cannot_verify', 16 | }; 17 | 18 | const VERIFIED_MODES = ['verified', 'professional', 'masters', 'executive_education']; 19 | 20 | export default IdVerificationContext; 21 | export { 22 | MEDIA_ACCESS, 23 | ERROR_REASONS, 24 | VERIFIED_MODES, 25 | }; 26 | -------------------------------------------------------------------------------- /src/id-verification/ImageFileUpload.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | import { intlShape } from '@edx/frontend-platform/i18n'; 3 | import PropTypes from 'prop-types'; 4 | import { Alert } from '@openedx/paragon'; 5 | import messages from './IdVerification.messages'; 6 | import SupportedMediaTypes from './SupportedMediaTypes'; 7 | 8 | const ImageFileUpload = ({ onFileChange, intl }) => { 9 | const [error, setError] = useState(null); 10 | const errorTypes = { 11 | invalidFileType: 'invalidFileType', 12 | fileTooLarge: 'fileTooLarge', 13 | }; 14 | const maxFileSize = 10000000; 15 | 16 | const handleChange = useCallback((e) => { 17 | if (e.target.files.length === 0) { 18 | return; 19 | } 20 | 21 | const fileObject = e.target.files[0]; 22 | if (!fileObject.type.startsWith('image')) { 23 | setError(errorTypes.invalidFileType); 24 | } else if (fileObject.size >= maxFileSize) { 25 | setError(errorTypes.fileTooLarge); 26 | } else { 27 | setError(null); 28 | const fileReader = new FileReader(); 29 | fileReader.addEventListener('load', () => { 30 | onFileChange(fileReader.result); 31 | }); 32 | fileReader.readAsDataURL(fileObject); 33 | } 34 | }, [errorTypes.fileTooLarge, errorTypes.invalidFileType, onFileChange]); 35 | 36 | return ( 37 | <> 38 | 44 | {error && ( 45 | 51 | {intl.formatMessage(messages[`id.verification.id.photo.instructions.upload.error.${error}`])} 52 | 53 | 54 | )} 55 | 56 | ); 57 | }; 58 | 59 | ImageFileUpload.propTypes = { 60 | onFileChange: PropTypes.func.isRequired, 61 | intl: intlShape.isRequired, 62 | }; 63 | 64 | export default ImageFileUpload; 65 | -------------------------------------------------------------------------------- /src/id-verification/ImagePreview.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ImagePreview = ({ src, alt, id }) => ( 5 |
    6 | 7 | {alt} 8 | 9 |
    10 | ); 11 | 12 | ImagePreview.propTypes = { 13 | src: PropTypes.string.isRequired, 14 | alt: PropTypes.string.isRequired, 15 | id: PropTypes.string, 16 | }; 17 | 18 | ImagePreview.defaultProps = { 19 | id: undefined, 20 | }; 21 | 22 | export default ImagePreview; 23 | -------------------------------------------------------------------------------- /src/id-verification/SupportedMediaTypes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SupportedMediaTypes = () => { 4 | const SUPPORTED_TYPES = ['.png', '.jpeg', '.jpg', '.bmp', '.webp', '.tiff']; 5 | 6 | const getSupportedTypes = () => SUPPORTED_TYPES.map((type, index) => { 7 | if (index === SUPPORTED_TYPES.length - 1) { 8 | return type; 9 | } 10 | return `${type}, `; 11 | }); 12 | 13 | return {getSupportedTypes()}; 14 | }; 15 | 16 | export default SupportedMediaTypes; 17 | -------------------------------------------------------------------------------- /src/id-verification/VerifiedNameContext.jsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useMemo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { getVerifiedNameHistory } from '../account-settings/data/service'; 5 | import { getMostRecentApprovedOrPendingVerifiedName } from '../utils'; 6 | import { useAsyncCall } from '../hooks'; 7 | import { SUCCESS_STATUS } from '../constants'; 8 | 9 | export const VerifiedNameContext = createContext(); 10 | 11 | export const VerifiedNameContextProvider = ({ children }) => { 12 | const verifiedNameHistoryData = useAsyncCall(getVerifiedNameHistory); 13 | 14 | let verifiedName = ''; 15 | const { status, data } = verifiedNameHistoryData; 16 | if (status === SUCCESS_STATUS && data) { 17 | const { results } = data; 18 | verifiedName = getMostRecentApprovedOrPendingVerifiedName(results); 19 | } 20 | 21 | const value = useMemo(() => ({ 22 | verifiedNameHistoryCallStatus: status, 23 | verifiedName, 24 | }), [status, verifiedName]); 25 | 26 | return ({children}); 27 | }; 28 | 29 | VerifiedNameContextProvider.propTypes = { 30 | children: PropTypes.node.isRequired, 31 | }; 32 | -------------------------------------------------------------------------------- /src/id-verification/_id-verification.scss: -------------------------------------------------------------------------------- 1 | .page__id-verification { 2 | .verification-panel { 3 | img { 4 | max-width: 100%; 5 | } 6 | .card.accent { 7 | border-top-width: 4px; 8 | border-top-style: solid; 9 | } 10 | .image-preview { 11 | margin-bottom: 1rem; 12 | max-width: 20rem; 13 | img { 14 | display: block; 15 | max-height: 10rem; 16 | } 17 | } 18 | } 19 | .action-row { 20 | display: flex; 21 | flex-direction: row-reverse; 22 | align-items: flex-start; 23 | margin-bottom: 12px; 24 | .btn-primary { 25 | min-width: 8rem; 26 | margin-left: 1rem; 27 | } 28 | .btn-link { 29 | padding-left: 0; 30 | padding-right: 0; 31 | text-decoration: underline; 32 | align-self: center; 33 | } 34 | } 35 | .camera-outer-wrapper { 36 | margin-bottom: 15px; 37 | text-align: center; 38 | border-radius: 0.3rem; 39 | } 40 | .camera-wrapper { 41 | position: relative; 42 | width: 100%; 43 | height: 100% 44 | } 45 | 46 | .camera-video { 47 | width: 100%; 48 | } 49 | 50 | .canvas-video { 51 | width: 100%; 52 | height: 100%; 53 | position: absolute; 54 | top: 0; 55 | left: 0; 56 | } 57 | 58 | .camera-btn { 59 | margin: 10px; 60 | } 61 | 62 | .camera-flash { 63 | position: absolute; 64 | height: 100%; 65 | width: 100%; 66 | opacity :1; 67 | transition:opacity .9s ease-out; 68 | } 69 | 70 | .camera-flash.do-transition { 71 | opacity: 0; 72 | background: white; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/id-verification/assets/example-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-app-account/d0eebfa0ea79d811425b195f54ce821ef9ab7508/src/id-verification/assets/example-card.png -------------------------------------------------------------------------------- /src/id-verification/data/selectors.js: -------------------------------------------------------------------------------- 1 | export const storeName = 'idVerifiction'; 2 | 3 | export const idVerificationSelector = state => ({ ...state[storeName] }); 4 | -------------------------------------------------------------------------------- /src/id-verification/data/service.js: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | 3 | import { getConfig } from '@edx/frontend-platform'; 4 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 5 | 6 | /** 7 | * Get ID verification status from LMS. 8 | * 9 | * Returns { 10 | * status: String, 11 | * expires: String|null, 12 | * canVerify: Boolean, 13 | * } 14 | */ 15 | export async function getExistingIdVerification() { 16 | const url = `${getConfig().LMS_BASE_URL}/verify_student/status/`; 17 | const requestConfig = { 18 | headers: { Accept: 'application/json' }, 19 | }; 20 | try { 21 | const response = await getAuthenticatedHttpClient().get(url, requestConfig); 22 | return { 23 | status: response.data.status || null, 24 | expires: response.data.expires || null, 25 | canVerify: response.data.can_verify || false, 26 | }; 27 | } catch (e) { 28 | return { status: null, expires: null, canVerify: false }; 29 | } 30 | } 31 | 32 | /** 33 | * Get the learner's enrollments. Used to check whether the learner is enrolled 34 | * in a verified course mode. 35 | * 36 | * Returns an array: [{...data, mode: String}] 37 | */ 38 | export async function getEnrollments() { 39 | const url = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`; 40 | const requestConfig = { 41 | headers: { Accept: 'application/json' }, 42 | }; 43 | try { 44 | const { data } = await getAuthenticatedHttpClient().get(url, requestConfig); 45 | return data; 46 | } catch (e) { 47 | return {}; 48 | } 49 | } 50 | 51 | /** 52 | * Submit ID verifiction to LMS. 53 | * 54 | * verificationData should take the shape of: 55 | * - facePhotoFile (String): Base64-encoded image. 56 | * - idPhotoFile (String|null): Optional Base64-encoded image 57 | * - idPhotoName (String|null): Optional string to change the user's name to. 58 | * - courseRunKey (String|null): Optional course run to redirect to. 59 | * 60 | * Returns { success: Boolean, message: String|null } 61 | */ 62 | export async function submitIdVerification(verificationData) { 63 | const keyMap = { 64 | facePhotoFile: 'face_image', 65 | idPhotoFile: 'photo_id_image', 66 | idPhotoName: 'full_name', 67 | }; 68 | const postData = {}; 69 | // Don't include blank/null/undefined values. 70 | // Note that this will also drop the value `false`. 71 | Object.keys(keyMap).forEach((jsKey) => { 72 | const apiKey = keyMap[jsKey]; 73 | if (verificationData[jsKey]) { 74 | postData[apiKey] = verificationData[jsKey]; 75 | } 76 | }); 77 | 78 | const url = `${getConfig().LMS_BASE_URL}/verify_student/submit-photos/`; 79 | const urlEncodedPostData = qs.stringify(postData); 80 | const requestConfig = { 81 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 82 | }; 83 | try { 84 | await getAuthenticatedHttpClient().post(url, urlEncodedPostData, requestConfig); 85 | return { success: true, message: null }; 86 | } catch (e) { 87 | return { 88 | success: false, 89 | status: e.customAttributes.httpErrorStatus, 90 | message: String(e), 91 | }; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/id-verification/getUserMediaShim.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This polyfill is from MDN: 3 | * https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia 4 | * 5 | * Their description: 6 | * "Here's an example of using navigator.mediaDevices.getUserMedia(), with a 7 | * polyfill to cope with older browsers. Note that this polyfill does not 8 | * correct for legacy differences in constraints syntax, which means constraints 9 | * won't work well across browsers. It is recommended to use the adapter.js 10 | * polyfill instead, which does handle constraints." 11 | * 12 | * Despite the lack of support for differences in constraints we'll use this 13 | * since it's small and simple and we don't have a need for constraints at the 14 | * moment. I've added an export hasGetUserMediaSupport before the polyfill to 15 | * help us understand support before making calls to getUserMedia. 16 | */ 17 | 18 | // IIFE to check getUserMedia support. Must be run before the polyfill. 19 | const hasGetUserMediaSupport = (() => { 20 | // Modern API 21 | if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { 22 | return true; 23 | } 24 | // Deprecated APIs 25 | if (navigator.webkitGetUserMedia || navigator.mozGetUserMedia) { 26 | return true; 27 | } 28 | return false; 29 | })(); 30 | 31 | // Older browsers might not implement mediaDevices at all, so we set an empty object first 32 | if (navigator.mediaDevices === undefined) { 33 | navigator.mediaDevices = {}; 34 | } 35 | 36 | // Some browsers partially implement mediaDevices. We can't just assign an object 37 | // with getUserMedia as it would overwrite existing properties. 38 | // Here, we will just add the getUserMedia property if it's missing. 39 | if (navigator.mediaDevices.getUserMedia === undefined) { 40 | // eslint-disable-next-line func-names 41 | navigator.mediaDevices.getUserMedia = function (constraints) { 42 | // First get ahold of the legacy getUserMedia, if present 43 | const getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia; 44 | 45 | // Some browsers just don't implement it - return a rejected promise with an error 46 | // to keep a consistent interface 47 | if (!getUserMedia) { 48 | return Promise.reject(new Error('getUserMedia is not implemented in this browser')); 49 | } 50 | 51 | // Otherwise, wrap the call to the old navigator.getUserMedia with a Promise 52 | return new Promise(((resolve, reject) => { 53 | getUserMedia.call(navigator, constraints, resolve, reject); 54 | })); 55 | }; 56 | } 57 | 58 | // eslint-disable-next-line import/prefer-default-export 59 | export { hasGetUserMediaSupport }; 60 | -------------------------------------------------------------------------------- /src/id-verification/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-exports 2 | export { default } from './IdVerificationPage'; 3 | -------------------------------------------------------------------------------- /src/id-verification/panels/BasePanel.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Navigate } from 'react-router-dom'; 4 | import { useVerificationRedirectSlug } from '../routing-utilities'; 5 | 6 | const BasePanel = ({ 7 | children, 8 | focusOnMount, 9 | name, 10 | title, 11 | }) => { 12 | const headingRef = useRef(); 13 | 14 | // focus heading element on mount 15 | useEffect(() => { 16 | if (focusOnMount && headingRef.current) { 17 | headingRef.current.focus(); 18 | } 19 | }, [focusOnMount]); 20 | 21 | const redirectSlug = useVerificationRedirectSlug(name); 22 | if (redirectSlug) { 23 | return ; 24 | } 25 | 26 | return ( 27 |
    28 |

    {title}

    29 | {children} 30 |
    31 | ); 32 | }; 33 | 34 | BasePanel.propTypes = { 35 | children: PropTypes.node.isRequired, 36 | focusOnMount: PropTypes.bool, 37 | name: PropTypes.string.isRequired, 38 | title: PropTypes.node.isRequired, 39 | }; 40 | 41 | BasePanel.defaultProps = { 42 | focusOnMount: true, 43 | }; 44 | 45 | export default BasePanel; 46 | -------------------------------------------------------------------------------- /src/id-verification/panels/IdContextPanel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | 5 | import { useNextPanelSlug } from '../routing-utilities'; 6 | import BasePanel from './BasePanel'; 7 | import CameraHelp from '../CameraHelp'; 8 | import messages from '../IdVerification.messages'; 9 | import exampleCard from '../assets/example-card.png'; 10 | 11 | const IdContextPanel = (props) => { 12 | const panelSlug = 'id-context'; 13 | const nextPanelSlug = useNextPanelSlug(panelSlug); 14 | return ( 15 | 19 |

    {props.intl.formatMessage(messages['id.verification.id.tips.description'])}

    20 |
    21 |
    22 |
    23 | {props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])} 24 |
    25 |

    26 | {props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])} 27 |

    28 |
      29 |
    • 30 | {props.intl.formatMessage(messages['id.verification.id.tips.list.well.lit'])} 31 |
    • 32 |
    • 33 | {props.intl.formatMessage(messages['id.verification.id.tips.list.clear'])} 34 |
    • 35 |
    36 | {props.intl.formatMessage(messages['id.verification.example.card.alt'])} 40 |
    41 |
    42 | 43 |
    44 | 45 | {props.intl.formatMessage(messages['id.verification.next'])} 46 | 47 |
    48 |
    49 | ); 50 | }; 51 | 52 | IdContextPanel.propTypes = { 53 | intl: intlShape.isRequired, 54 | }; 55 | 56 | export default injectIntl(IdContextPanel); 57 | -------------------------------------------------------------------------------- /src/id-verification/panels/PortraitPhotoContextPanel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | 5 | import { useNextPanelSlug } from '../routing-utilities'; 6 | import BasePanel from './BasePanel'; 7 | import CameraHelp from '../CameraHelp'; 8 | import messages from '../IdVerification.messages'; 9 | 10 | const PortraitPhotoContextPanel = (props) => { 11 | const panelSlug = 'portrait-photo-context'; 12 | const nextPanelSlug = useNextPanelSlug(panelSlug); 13 | return ( 14 | 18 |

    19 | {props.intl.formatMessage(messages['id.verification.photo.tips.description'])} 20 |

    21 |
    22 |
    23 |
    24 | {props.intl.formatMessage(messages['id.verification.photo.tips.list.title'])} 25 |
    26 |

    27 | {props.intl.formatMessage(messages['id.verification.photo.tips.list.description'])} 28 |

    29 |
      30 |
    • 31 | {props.intl.formatMessage(messages['id.verification.photo.tips.list.well.lit'])} 32 |
    • 33 |
    • 34 | {props.intl.formatMessage(messages['id.verification.photo.tips.list.inside.frame'])} 35 |
    • 36 |
    37 |
    38 |
    39 | 40 |
    41 | 42 | {props.intl.formatMessage(messages['id.verification.next'])} 43 | 44 |
    45 |
    46 | ); 47 | }; 48 | 49 | PortraitPhotoContextPanel.propTypes = { 50 | intl: intlShape.isRequired, 51 | }; 52 | 53 | export default injectIntl(PortraitPhotoContextPanel); 54 | -------------------------------------------------------------------------------- /src/id-verification/panels/SubmittedPanel.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react'; 2 | import { getConfig } from '@edx/frontend-platform'; 3 | import { sendTrackEvent } from '@edx/frontend-platform/analytics'; 4 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 5 | 6 | import { useRedirect } from '../../hooks'; 7 | 8 | import IdVerificationContext from '../IdVerificationContext'; 9 | import messages from '../IdVerification.messages'; 10 | 11 | import BasePanel from './BasePanel'; 12 | 13 | const SubmittedPanel = (props) => { 14 | const { userId } = useContext(IdVerificationContext); 15 | const { location: returnUrl, text: returnText } = useRedirect(); 16 | const panelSlug = 'submitted'; 17 | 18 | useEffect(() => { 19 | sendTrackEvent('edx.id_verification.submitted', { 20 | category: 'id_verification', 21 | user_id: userId, 22 | }); 23 | }, [userId]); 24 | 25 | return ( 26 | 30 |

    31 | {props.intl.formatMessage(messages['id.verification.submitted.text'])} 32 |

    33 | 38 | {props.intl.formatMessage(messages[returnText])} 39 | 40 |
    41 | ); 42 | }; 43 | 44 | SubmittedPanel.propTypes = { 45 | intl: intlShape.isRequired, 46 | }; 47 | 48 | export default injectIntl(SubmittedPanel); 49 | -------------------------------------------------------------------------------- /src/id-verification/panels/TakeIdPhotoPanel.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | 5 | import { useNextPanelSlug } from '../routing-utilities'; 6 | import BasePanel from './BasePanel'; 7 | import Camera from '../Camera'; 8 | import IdVerificationContext from '../IdVerificationContext'; 9 | 10 | import messages from '../IdVerification.messages'; 11 | import CameraHelp from '../CameraHelp'; 12 | import ImagePreview from '../ImagePreview'; 13 | import ImageFileUpload from '../ImageFileUpload'; 14 | import CollapsibleImageHelp from '../CollapsibleImageHelp'; 15 | import SupportedMediaTypes from '../SupportedMediaTypes'; 16 | 17 | const TakeIdPhotoPanel = (props) => { 18 | const panelSlug = 'take-id-photo'; 19 | const nextPanelSlug = useNextPanelSlug(panelSlug); 20 | const { setIdPhotoFile, idPhotoFile, useCameraForId } = useContext(IdVerificationContext); 21 | const [mounted, setMounted] = useState(false); 22 | 23 | useEffect(() => { 24 | // This prevents focus switching to the heading when taking a photo 25 | setMounted(true); 26 | }, []); 27 | 28 | return ( 29 | 36 |
    37 | {idPhotoFile && !useCameraForId && ( 38 | 42 | )} 43 | 44 | {useCameraForId ? ( 45 |
    46 |

    47 | {props.intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])} 48 |

    49 | 50 |
    51 | ) : ( 52 |
    53 |

    54 | {props.intl.formatMessage(messages['id.verification.id.photo.instructions.upload'])} 55 | 56 |

    57 | 58 |
    59 | )} 60 |
    61 | {useCameraForId && } 62 | 63 |
    64 | 65 | {props.intl.formatMessage(messages['id.verification.next'])} 66 | 67 |
    68 |
    69 | ); 70 | }; 71 | 72 | TakeIdPhotoPanel.propTypes = { 73 | intl: intlShape.isRequired, 74 | }; 75 | 76 | export default injectIntl(TakeIdPhotoPanel); 77 | -------------------------------------------------------------------------------- /src/id-verification/panels/TakePortraitPhotoPanel.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | 5 | import { useNextPanelSlug } from '../routing-utilities'; 6 | import BasePanel from './BasePanel'; 7 | import Camera from '../Camera'; 8 | import CameraHelp from '../CameraHelp'; 9 | import IdVerificationContext from '../IdVerificationContext'; 10 | 11 | import messages from '../IdVerification.messages'; 12 | 13 | const TakePortraitPhotoPanel = (props) => { 14 | const panelSlug = 'take-portrait-photo'; 15 | const nextPanelSlug = useNextPanelSlug(panelSlug); 16 | const { setFacePhotoFile, facePhotoFile } = useContext(IdVerificationContext); 17 | const [mounted, setMounted] = useState(false); 18 | 19 | useEffect(() => { 20 | // This prevents focus switching to the heading when taking a photo 21 | setMounted(true); 22 | }, []); 23 | 24 | return ( 25 | 30 |
    31 |

    32 | {props.intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])} 33 |

    34 | 35 |
    36 | 37 |
    38 | 39 | {props.intl.formatMessage(messages['id.verification.next'])} 40 | 41 |
    42 |
    43 | ); 44 | }; 45 | 46 | TakePortraitPhotoPanel.propTypes = { 47 | intl: intlShape.isRequired, 48 | }; 49 | 50 | export default injectIntl(TakePortraitPhotoPanel); 51 | -------------------------------------------------------------------------------- /src/id-verification/panels/UnsupportedCameraDirectionsPanel.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 4 | import messages from '../IdVerification.messages'; 5 | 6 | export const UnsupportedCameraDirectionsPanel = (props) => ( 7 | <> 8 | {props.browserName === 'Chrome' && {props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.chrome.explanation'])}} 9 | 10 | {props.intl.formatMessage(messages['id.verification.camera.access.failure.unsupported.instructions'])} 11 | 12 | ); 13 | 14 | UnsupportedCameraDirectionsPanel.propTypes = { 15 | intl: intlShape.isRequired, 16 | browserName: PropTypes.string.isRequired, 17 | }; 18 | 19 | export default injectIntl(UnsupportedCameraDirectionsPanel); 20 | -------------------------------------------------------------------------------- /src/id-verification/routing-utilities.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import IdVerificationContext from './IdVerificationContext'; 3 | 4 | const SLUGS = { 5 | REVIEW_REQUIREMENTS: 'review-requirements', 6 | CHOOSE_MODE: 'choose-mode', 7 | REQUEST_CAMERA_ACCESS: 'request-camera-access', 8 | PORTRAIT_PHOTO_CONTEXT: 'portrait-photo-context', 9 | TAKE_PORTRAIT_PHOTO: 'take-portrait-photo', 10 | ID_CONTEXT: 'id-context', 11 | TAKE_ID_PHOTO: 'take-id-photo', 12 | GET_NAME_ID: 'get-name-id', 13 | SUMMARY: 'summary', 14 | SUBMITTED: 'submitted', 15 | }; 16 | 17 | const panelSteps = [ 18 | SLUGS.REVIEW_REQUIREMENTS, 19 | SLUGS.REQUEST_CAMERA_ACCESS, 20 | SLUGS.PORTRAIT_PHOTO_CONTEXT, 21 | SLUGS.TAKE_PORTRAIT_PHOTO, 22 | SLUGS.ID_CONTEXT, 23 | SLUGS.TAKE_ID_PHOTO, 24 | SLUGS.GET_NAME_ID, 25 | SLUGS.SUMMARY, 26 | SLUGS.SUBMITTED, 27 | ]; 28 | 29 | // eslint-disable-next-line import/prefer-default-export 30 | export const useNextPanelSlug = (originSlug) => { 31 | // Go back to the summary view if that's where they came from 32 | const { reachedSummary } = useContext(IdVerificationContext); 33 | 34 | const canRerouteToSummary = [ 35 | SLUGS.TAKE_PORTRAIT_PHOTO, 36 | SLUGS.TAKE_ID_PHOTO, 37 | SLUGS.GET_NAME_ID, 38 | ]; 39 | 40 | if (reachedSummary && canRerouteToSummary.includes(originSlug)) { 41 | return SLUGS.SUMMARY; 42 | } 43 | 44 | const nextIndex = panelSteps.indexOf(originSlug) + 1; 45 | return nextIndex < panelSteps.length ? panelSteps[nextIndex] : null; 46 | }; 47 | 48 | // check if the user is too far into the flow and if so, return the slug of the 49 | // furthest panel they are allow to be. 50 | export const useVerificationRedirectSlug = (slug) => { 51 | const { facePhotoFile, idPhotoFile } = useContext(IdVerificationContext); 52 | const indexOfCurrentPanel = panelSteps.indexOf(slug); 53 | if (!facePhotoFile) { 54 | if (indexOfCurrentPanel > panelSteps.indexOf(SLUGS.TAKE_PORTRAIT_PHOTO)) { 55 | return SLUGS.PORTRAIT_PHOTO_CONTEXT; 56 | } 57 | } else if (!idPhotoFile) { 58 | if (indexOfCurrentPanel > panelSteps.indexOf(SLUGS.TAKE_ID_PHOTO)) { 59 | return SLUGS.ID_CONTEXT; 60 | } 61 | } 62 | 63 | // The user has satisfied requirements to view the panel they're on. 64 | return null; 65 | }; 66 | -------------------------------------------------------------------------------- /src/id-verification/tests/AccessBlocked.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import { 4 | render, cleanup, act, screen, 5 | } from '@testing-library/react'; 6 | import '@edx/frontend-platform/analytics'; 7 | import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; 8 | 9 | import { ERROR_REASONS } from '../IdVerificationContext'; 10 | import AccessBlocked from '../AccessBlocked'; 11 | 12 | const IntlAccessBlocked = injectIntl(AccessBlocked); 13 | 14 | describe('AccessBlocked', () => { 15 | const defaultProps = { 16 | intl: {}, 17 | error: '', 18 | }; 19 | 20 | afterEach(() => { 21 | cleanup(); 22 | }); 23 | 24 | it('renders correctly when there is an existing request', async () => { 25 | defaultProps.error = ERROR_REASONS.EXISTING_REQUEST; 26 | 27 | await act(async () => render(( 28 | 29 | 30 | 31 | 32 | 33 | ))); 34 | 35 | const text = screen.getByText(/You have already submitted your verification information./); 36 | 37 | expect(text).toBeInTheDocument(); 38 | }); 39 | 40 | it('renders correctly when learner is not enrolled in a verified course mode', async () => { 41 | defaultProps.error = ERROR_REASONS.COURSE_ENROLLMENT; 42 | 43 | await act(async () => render(( 44 | 45 | 46 | 47 | 48 | 49 | ))); 50 | 51 | const text = screen.getByText(/You are not currently enrolled in a course that requires identity verification./); 52 | 53 | expect(text).toBeInTheDocument(); 54 | }); 55 | 56 | it('renders correctly when status is denied', async () => { 57 | defaultProps.error = ERROR_REASONS.CANNOT_VERIFY; 58 | 59 | await act(async () => render(( 60 | 61 | 62 | 63 | 64 | 65 | ))); 66 | 67 | const text = screen.getByText(/We cannot verify your identity at this time./); 68 | 69 | expect(text).toBeInTheDocument(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/id-verification/tests/CollapsibleImageHelp.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import { 4 | render, cleanup, screen, act, 5 | } from '@testing-library/react'; 6 | import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; 7 | import * as analytics from '@edx/frontend-platform/analytics'; 8 | import IdVerificationContext from '../IdVerificationContext'; 9 | import CollapsibleImageHelp from '../CollapsibleImageHelp'; 10 | 11 | jest.mock('jslib-html5-camera-photo'); 12 | jest.mock('@tensorflow-models/blazeface'); 13 | jest.mock('@edx/frontend-platform/analytics'); 14 | 15 | // eslint-disable-next-line no-import-assign 16 | analytics.sendTrackEvent = jest.fn(); 17 | 18 | window.HTMLMediaElement.prototype.play = () => {}; 19 | 20 | const IntlCollapsible = injectIntl(CollapsibleImageHelp); 21 | 22 | describe('CollapsibleImageHelpPanel', () => { 23 | const defaultProps = { intl: {} }; 24 | 25 | const contextValue = { 26 | useCameraForId: true, 27 | setUseCameraForId: jest.fn(), 28 | }; 29 | 30 | afterEach(() => { 31 | cleanup(); 32 | }); 33 | 34 | it('shows the correct text if user should switch to upload', async () => { 35 | await act(async () => render(( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ))); 44 | 45 | const titleText = screen.getByText('Upload a Photo Instead'); 46 | expect(titleText).toBeInTheDocument(); 47 | const helpText = screen.getByTestId('help-text'); 48 | expect(helpText.textContent).toContain('If you are having trouble using the photo capture above'); 49 | const button = screen.getByTestId('toggle-button'); 50 | expect(button).toHaveTextContent('Switch to Upload Mode'); 51 | }); 52 | 53 | it('shows the correct text if user should switch to camera', async () => { 54 | contextValue.useCameraForId = false; 55 | await act(async () => render(( 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ))); 64 | 65 | const titleText = screen.getByText('Use Your Camera Instead'); 66 | expect(titleText).toBeInTheDocument(); 67 | const helpText = screen.getByTestId('help-text'); 68 | expect(helpText.textContent).toContain('If you are having trouble uploading a photo above'); 69 | const button = screen.getByTestId('toggle-button'); 70 | expect(button).toHaveTextContent('Switch to Camera Mode'); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/id-verification/tests/IdVerificationContextProvider.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup, act } from '@testing-library/react'; 3 | 4 | import { IntlProvider } from '@edx/frontend-platform/i18n'; 5 | import { AppContext } from '@edx/frontend-platform/react'; 6 | 7 | import { getProfileDataManager } from '../../account-settings/data/service'; 8 | 9 | import { getExistingIdVerification, getEnrollments } from '../data/service'; 10 | import IdVerificationContextProvider from '../IdVerificationContextProvider'; 11 | import { VerifiedNameContext } from '../VerifiedNameContext'; 12 | 13 | jest.mock('../../account-settings/data/service', () => ({ 14 | getProfileDataManager: jest.fn(), 15 | getVerifiedNameHistory: jest.fn(), 16 | })); 17 | 18 | jest.mock('../data/service', () => ({ 19 | getExistingIdVerification: jest.fn(() => ({})), 20 | getEnrollments: jest.fn(() => ({})), 21 | })); 22 | 23 | describe('IdVerificationContextProvider', () => { 24 | const defaultProps = { 25 | children:
    , 26 | intl: {}, 27 | }; 28 | 29 | afterEach(() => { 30 | cleanup(); 31 | }); 32 | 33 | it('renders correctly and calls getExistingIdVerification + getEnrollments', async () => { 34 | const appContext = { authenticatedUser: { userId: 3, roles: [] } }; 35 | const verifiedNameContext = { verifiedName: '' }; 36 | await act(async () => render(( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ))); 45 | expect(getExistingIdVerification).toHaveBeenCalled(); 46 | expect(getEnrollments).toHaveBeenCalled(); 47 | }); 48 | 49 | it('calls getProfileDataManager if the user has any roles', async () => { 50 | const appContext = { 51 | authenticatedUser: { 52 | userId: 3, 53 | username: 'testname', 54 | roles: ['enterprise_learner'], 55 | }, 56 | }; 57 | const verifiedNameContext = { verifiedName: '' }; 58 | await act(async () => render(( 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ))); 67 | expect(getProfileDataManager).toHaveBeenCalledWith( 68 | appContext.authenticatedUser.username, 69 | appContext.authenticatedUser.roles, 70 | ); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/id-verification/tests/VerifiedNameContextProvider.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-useless-fragment */ 2 | import React, { useContext } from 'react'; 3 | import { render, cleanup, waitFor } from '@testing-library/react'; 4 | 5 | import { getVerifiedNameHistory } from '../../account-settings/data/service'; 6 | import { VerifiedNameContext, VerifiedNameContextProvider } from '../VerifiedNameContext'; 7 | 8 | const VerifiedNameContextTestComponent = () => { 9 | const { verifiedName } = useContext(VerifiedNameContext); 10 | return ( 11 | <> 12 | {verifiedName && (
    {verifiedName}
    )} 13 | 14 | ); 15 | }; 16 | 17 | jest.mock('../../account-settings/data/service', () => ({ 18 | getVerifiedNameHistory: jest.fn(() => ({})), 19 | })); 20 | 21 | describe('VerifiedNameContextProvider', () => { 22 | const defaultProps = { 23 | children:
    , 24 | intl: {}, 25 | }; 26 | 27 | afterEach(() => { 28 | cleanup(); 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | it('calls getVerifiedNameHistory', async () => { 33 | jest.mock('../../account-settings/data/service', () => ({ 34 | getVerifiedNameHistory: jest.fn(() => ({})), 35 | })); 36 | 37 | render(); 38 | await waitFor(() => expect(getVerifiedNameHistory).toHaveBeenCalledTimes(1)); 39 | }); 40 | 41 | it('sets verifiedName', async () => { 42 | const mockReturnValue = { 43 | results: [{ 44 | verified_name: 'Michael', 45 | status: 'approved', 46 | created: '2021-08-31T18:33:32.489200Z', 47 | }], 48 | }; 49 | getVerifiedNameHistory.mockReturnValueOnce(mockReturnValue); 50 | 51 | const { getByTestId } = render(( 52 | 53 | 54 | 55 | )); 56 | 57 | await waitFor(() => expect(getVerifiedNameHistory).toHaveBeenCalledTimes(1)); 58 | expect(getByTestId('verified-name')).toHaveTextContent('Michael'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/id-verification/tests/panels/GetNameIdPanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import { 4 | render, cleanup, act, screen, fireEvent, 5 | } from '@testing-library/react'; 6 | import '@edx/frontend-platform/analytics'; 7 | import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; 8 | import IdVerificationContext from '../../IdVerificationContext'; 9 | import { VerifiedNameContext } from '../../VerifiedNameContext'; 10 | import GetNameIdPanel from '../../panels/GetNameIdPanel'; 11 | 12 | jest.mock('@edx/frontend-platform/analytics', () => ({ 13 | sendTrackEvent: jest.fn(), 14 | })); 15 | 16 | const IntlGetNameIdPanel = injectIntl(GetNameIdPanel); 17 | 18 | describe('GetNameIdPanel', () => { 19 | const defaultProps = { 20 | intl: {}, 21 | }; 22 | 23 | const IDVerificationContextValue = { 24 | nameOnAccount: 'test', 25 | userId: 3, 26 | idPhotoName: '', 27 | setIdPhotoName: jest.fn(), 28 | facePhotoFile: 'test.jpg', 29 | idPhotoFile: 'test.jpg', 30 | }; 31 | 32 | const verifiedNameContextValue = {}; 33 | 34 | const getPanel = async (idVerificationContextValue = IDVerificationContextValue) => { 35 | await act(async () => render(( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ))); 46 | }; 47 | 48 | afterEach(() => { 49 | cleanup(); 50 | }); 51 | 52 | it('shows feedback message when user has an empty name', async () => { 53 | await getPanel(); 54 | // Ensure the feedback message on name shows when the user has an empty name 55 | expect(await screen.queryByTestId('id-name-feedback-message')).toBeTruthy(); 56 | }); 57 | 58 | it('does not show feedback message when user has an non-empty name', async () => { 59 | const idVerificationContextValue = { 60 | ...IDVerificationContextValue, 61 | idPhotoName: 'test', 62 | }; 63 | await getPanel(idVerificationContextValue); 64 | // Ensure the feedback message on name shows when the user has an empty name 65 | expect(await screen.queryByTestId('id-name-feedback-message')).toBeNull(); 66 | }); 67 | 68 | it('calls setIdPhotoName with correct name', async () => { 69 | await getPanel(); 70 | 71 | const input = await screen.findByTestId('name-input'); 72 | fireEvent.change(input, { target: { value: 'test' } }); 73 | expect(IDVerificationContextValue.setIdPhotoName).toHaveBeenCalledWith('test'); 74 | }); 75 | 76 | it('routes to SummaryPanel', async () => { 77 | await getPanel(); 78 | 79 | const button = await screen.findByTestId('next-button'); 80 | 81 | fireEvent.click(button); 82 | expect(window.location.pathname).toEqual('/id-verification/summary'); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/id-verification/tests/panels/IdContextPanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import { 4 | render, cleanup, act, screen, fireEvent, 5 | } from '@testing-library/react'; 6 | import '@edx/frontend-platform/analytics'; 7 | import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; 8 | import IdVerificationContext from '../../IdVerificationContext'; 9 | import IdContextPanel from '../../panels/IdContextPanel'; 10 | 11 | jest.mock('@edx/frontend-platform/analytics', () => ({ 12 | sendTrackEvent: jest.fn(), 13 | })); 14 | 15 | const IntlIdContextPanel = injectIntl(IdContextPanel); 16 | 17 | describe('IdContextPanel', () => { 18 | const defaultProps = { 19 | intl: {}, 20 | }; 21 | 22 | const contextValue = { 23 | facePhotoFile: 'test.jpg', 24 | reachedSummary: false, 25 | }; 26 | 27 | afterEach(() => { 28 | cleanup(); 29 | }); 30 | 31 | it('routes to TakeIdPhotoPanel normally', async () => { 32 | await act(async () => render(( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ))); 41 | const button = await screen.findByTestId('next-button'); 42 | fireEvent.click(button); 43 | expect(window.location.pathname).toEqual('/id-verification/take-id-photo'); 44 | }); 45 | 46 | it('routes to TakeIdPhotoPanel if reachedSummary is true', async () => { 47 | contextValue.reachedSummary = true; 48 | await act(async () => render(( 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ))); 57 | const button = await screen.findByTestId('next-button'); 58 | fireEvent.click(button); 59 | expect(window.location.pathname).toEqual('/id-verification/take-id-photo'); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/id-verification/tests/panels/PortraitPhotoContextPanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import { 4 | render, cleanup, act, screen, fireEvent, 5 | } from '@testing-library/react'; 6 | import '@edx/frontend-platform/analytics'; 7 | import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; 8 | import PortraitPhotoContextPanel from '../../panels/PortraitPhotoContextPanel'; 9 | import IdVerificationContext from '../../IdVerificationContext'; 10 | 11 | jest.mock('@edx/frontend-platform/analytics', () => ({ 12 | sendTrackEvent: jest.fn(), 13 | })); 14 | 15 | const IntlPortraitPhotoContextPanel = injectIntl(PortraitPhotoContextPanel); 16 | 17 | describe('PortraitPhotoContextPanel', () => { 18 | const defaultProps = { 19 | intl: {}, 20 | }; 21 | 22 | const contextValue = { reachedSummary: false }; 23 | 24 | afterEach(() => { 25 | cleanup(); 26 | }); 27 | 28 | it('routes to TakePortraitPhotoPanel normally', async () => { 29 | await act(async () => render(( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ))); 38 | const button = await screen.findByTestId('next-button'); 39 | fireEvent.click(button); 40 | expect(window.location.pathname).toEqual('/id-verification/take-portrait-photo'); 41 | }); 42 | 43 | it('routes to TakePortraitPhotoPanel if reachedSummary is true', async () => { 44 | contextValue.reachedSummary = true; 45 | await act(async () => render(( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | ))); 54 | const button = await screen.findByTestId('next-button'); 55 | fireEvent.click(button); 56 | expect(window.location.pathname).toEqual('/id-verification/take-portrait-photo'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/id-verification/tests/panels/ReviewRequirementsPanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import { 4 | render, cleanup, act, screen, fireEvent, 5 | } from '@testing-library/react'; 6 | import '@edx/frontend-platform/analytics'; 7 | import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; 8 | import IdVerificationContext from '../../IdVerificationContext'; 9 | import ReviewRequirementsPanel from '../../panels/ReviewRequirementsPanel'; 10 | 11 | jest.mock('@edx/frontend-platform/analytics', () => ({ 12 | sendTrackEvent: jest.fn(), 13 | })); 14 | 15 | const IntlReviewRequirementsPanel = injectIntl(ReviewRequirementsPanel); 16 | 17 | describe('ReviewRequirementsPanel', () => { 18 | const defaultProps = { 19 | intl: {}, 20 | }; 21 | 22 | const context = {}; 23 | 24 | const getPanel = async () => { 25 | await act(async () => render(( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ))); 34 | }; 35 | 36 | afterEach(() => { 37 | cleanup(); 38 | }); 39 | 40 | it('routes to RequestCameraAccessPanel', async () => { 41 | await getPanel(); 42 | const button = await screen.findByTestId('next-button'); 43 | fireEvent.click(button); 44 | expect(window.location.pathname).toEqual('/id-verification/request-camera-access'); 45 | }); 46 | 47 | it('displays an alert if the user\'s account information is managed by a third party', async () => { 48 | context.profileDataManager = 'test-org'; 49 | await getPanel(); 50 | const alert = await screen.getAllByText('test-org'); 51 | expect(alert.length).toEqual(1); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/id-verification/tests/panels/TakePortraitPhotoPanel.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-useless-fragment */ 2 | import React from 'react'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | import { 5 | render, cleanup, act, screen, fireEvent, 6 | } from '@testing-library/react'; 7 | import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; 8 | import IdVerificationContext from '../../IdVerificationContext'; 9 | import TakePortraitPhotoPanel from '../../panels/TakePortraitPhotoPanel'; 10 | 11 | jest.mock('@edx/frontend-platform/analytics', () => ({ 12 | sendTrackEvent: jest.fn(), 13 | })); 14 | 15 | jest.mock('../../Camera', () => function CameraMock() { 16 | return <>; 17 | }); 18 | 19 | const IntlTakePortraitPhotoPanel = injectIntl(TakePortraitPhotoPanel); 20 | 21 | describe('TakePortraitPhotoPanel', () => { 22 | const defaultProps = { 23 | intl: {}, 24 | }; 25 | 26 | const contextValue = { 27 | facePhotoFile: null, 28 | idPhotoFile: null, 29 | reachedSummary: false, 30 | setFacePhotoFile: jest.fn(), 31 | }; 32 | 33 | afterEach(() => { 34 | cleanup(); 35 | }); 36 | 37 | it('doesn\'t show next button before photo is taken', async () => { 38 | await act(async () => render(( 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ))); 47 | const button = await screen.findByTestId('next-button'); 48 | expect(button).not.toBeVisible(); 49 | }); 50 | 51 | it('shows next button after photo is taken and routes to IdContextPanel', async () => { 52 | contextValue.facePhotoFile = 'test.jpg'; 53 | await act(async () => render(( 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ))); 62 | const button = await screen.findByTestId('next-button'); 63 | expect(button).toBeVisible(); 64 | fireEvent.click(button); 65 | expect(window.location.pathname).toEqual('/id-verification/id-context'); 66 | }); 67 | 68 | it('routes back to SummaryPanel if that was the source', async () => { 69 | contextValue.facePhotoFile = 'test.jpg'; 70 | contextValue.idPhotoFile = 'test.jpg'; 71 | contextValue.reachedSummary = true; 72 | await act(async () => render(( 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | ))); 81 | const button = await screen.findByTestId('next-button'); 82 | fireEvent.click(button); 83 | expect(window.location.pathname).toEqual('/id-verification/summary'); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import 'regenerator-runtime/runtime'; 3 | 4 | import 'formdata-polyfill'; 5 | import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; 6 | import { 7 | subscribe, initialize, APP_INIT_ERROR, APP_READY, mergeConfig, 8 | } from '@edx/frontend-platform'; 9 | import React, { StrictMode } from 'react'; 10 | // eslint-disable-next-line import/no-unresolved 11 | import { createRoot } from 'react-dom/client'; 12 | import { Route, Routes, Outlet } from 'react-router-dom'; 13 | 14 | import Header from '@edx/frontend-component-header'; 15 | import { FooterSlot } from '@edx/frontend-component-footer'; 16 | 17 | import configureStore from './data/configureStore'; 18 | import AccountSettingsPage, { NotFoundPage } from './account-settings'; 19 | import IdVerificationPageSlot from './plugin-slots/IdVerificationPageSlot'; 20 | import messages from './i18n'; 21 | 22 | import './index.scss'; 23 | import Head from './head/Head'; 24 | 25 | const rootNode = createRoot(document.getElementById('root')); 26 | subscribe(APP_READY, () => { 27 | rootNode.render( 28 | 29 | 30 | 31 | 32 | 34 |
    35 |
    36 | 37 |
    38 | 39 |
    40 | )} 41 | > 42 | } 45 | /> 46 | } /> 47 | } /> 48 | } /> 49 | 50 | 51 | 52 | , 53 | ); 54 | }); 55 | 56 | subscribe(APP_INIT_ERROR, (error) => { 57 | rootNode.render(); 58 | }); 59 | 60 | initialize({ 61 | messages, 62 | requireAuthenticatedUser: true, 63 | hydrateAuthenticatedUser: true, 64 | handlers: { 65 | config: () => { 66 | mergeConfig({ 67 | SUPPORT_URL: process.env.SUPPORT_URL, 68 | SHOW_EMAIL_CHANNEL: process.env.SHOW_EMAIL_CHANNEL || 'false', 69 | SHOW_IMMEDIATE_EMAIL_CADENCE: process.env.SHOW_IMMEDIATE_EMAIL_CADENCE === 'true', 70 | ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false), 71 | ENABLE_ACCOUNT_DELETION: (process.env.ENABLE_ACCOUNT_DELETION !== 'false'), 72 | COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED: JSON.parse(process.env.COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED || '[]'), 73 | ENABLE_DOB_UPDATE: (process.env.ENABLE_DOB_UPDATE || false), 74 | MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false), 75 | PASSWORD_RESET_SUPPORT_LINK: process.env.PASSWORD_RESET_SUPPORT_LINK, 76 | LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL, 77 | }, 'App loadConfig override handler'); 78 | }, 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /src/notification-preferences/EmailCadences.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import { getConfig } from '@edx/frontend-platform'; 7 | import { useIntl } from '@edx/frontend-platform/i18n'; 8 | import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; 9 | import { 10 | Button, Dropdown, ModalPopup, useToggle, 11 | } from '@openedx/paragon'; 12 | 13 | import messages from './messages'; 14 | import { EMAIL_CADENCE_PREFERENCES, EMAIL_CADENCE } from './data/constants'; 15 | import { selectUpdatePreferencesStatus } from './data/selectors'; 16 | import { LOADING_STATUS } from '../constants'; 17 | 18 | const EmailCadences = ({ 19 | email, onToggle, emailCadence, notificationType, 20 | }) => { 21 | const intl = useIntl(); 22 | const [isOpen, open, close] = useToggle(false); 23 | const [target, setTarget] = useState(null); 24 | const updatePreferencesStatus = useSelector(selectUpdatePreferencesStatus()); 25 | if (getConfig().SHOW_IMMEDIATE_EMAIL_CADENCE) { 26 | EMAIL_CADENCE_PREFERENCES.IMMEDIATELY = 'Immediately'; 27 | } 28 | 29 | return ( 30 | <> 31 | 43 | 48 |
    52 | {Object.values(EMAIL_CADENCE_PREFERENCES).map((cadence) => ( 53 | { 64 | onToggle(event, notificationType); 65 | close(); 66 | }} 67 | > 68 | {intl.formatMessage(messages.emailCadence, { text: cadence })} 69 | 70 | ))} 71 |
    72 |
    73 | 74 | ); 75 | }; 76 | 77 | EmailCadences.propTypes = { 78 | email: PropTypes.bool.isRequired, 79 | onToggle: PropTypes.func.isRequired, 80 | emailCadence: PropTypes.oneOf(Object.values(EMAIL_CADENCE_PREFERENCES)).isRequired, 81 | notificationType: PropTypes.string.isRequired, 82 | }; 83 | 84 | export default React.memo(EmailCadences); 85 | -------------------------------------------------------------------------------- /src/notification-preferences/NotificationCoursesDropdown.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import { useIntl } from '@edx/frontend-platform/i18n'; 5 | import { Dropdown } from '@openedx/paragon'; 6 | 7 | import { IDLE_STATUS, SUCCESS_STATUS } from '../constants'; 8 | import { selectCourseList, selectCourseListStatus, selectSelectedCourseId } from './data/selectors'; 9 | import { fetchCourseList, setSelectedCourse } from './data/thunks'; 10 | import messages from './messages'; 11 | 12 | const NotificationCoursesDropdown = () => { 13 | const intl = useIntl(); 14 | const dispatch = useDispatch(); 15 | const coursesList = useSelector(selectCourseList()); 16 | const courseListStatus = useSelector(selectCourseListStatus()); 17 | const selectedCourseId = useSelector(selectSelectedCourseId()); 18 | const selectedCourse = useMemo( 19 | () => coursesList.find((course) => course.id === selectedCourseId), 20 | [coursesList, selectedCourseId], 21 | ); 22 | 23 | const handleCourseSelection = useCallback((courseId) => { 24 | dispatch(setSelectedCourse(courseId)); 25 | }, [dispatch]); 26 | 27 | const fetchCourses = useCallback((page = 1, pageSize = 99999) => { 28 | dispatch(fetchCourseList(page, pageSize)); 29 | }, [dispatch]); 30 | 31 | useEffect(() => { 32 | if (courseListStatus === IDLE_STATUS) { 33 | fetchCourses(); 34 | } 35 | }, [courseListStatus, fetchCourses]); 36 | 37 | return ( 38 | courseListStatus === SUCCESS_STATUS && ( 39 |
    40 |
    {intl.formatMessage(messages.notificationDropdownlabel)}
    41 | 42 | 48 | {selectedCourse?.name} 49 | 50 | 51 | {coursesList.map((course) => ( 52 | 59 | {course.name} 60 | 61 | ))} 62 | 63 | 64 | 65 | {selectedCourse?.name === 'Account' 66 | ? intl.formatMessage(messages.notificationDropdownApplies) 67 | : intl.formatMessage(messages.notificationCourseDropdownApplies)} 68 | 69 |
    70 | ) 71 | ); 72 | }; 73 | 74 | export default NotificationCoursesDropdown; 75 | -------------------------------------------------------------------------------- /src/notification-preferences/NotificationPreferenceApp.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import classNames from 'classnames'; 5 | import { useSelector } from 'react-redux'; 6 | 7 | import { Collapsible } from '@openedx/paragon'; 8 | import { useIntl } from '@edx/frontend-platform/i18n'; 9 | import messages from './messages'; 10 | import { useIsOnMobile } from '../hooks'; 11 | import NotificationTypes from './NotificationTypes'; 12 | import { notificationChannels, shouldHideAppPreferences } from './data/utils'; 13 | import NotificationPreferenceColumn from './NotificationPreferenceColumn'; 14 | import { selectPreferenceAppToggleValue, selectAppPreferences } from './data/selectors'; 15 | 16 | const NotificationPreferenceApp = ({ appId }) => { 17 | const intl = useIntl(); 18 | const appToggle = useSelector(selectPreferenceAppToggleValue(appId)); 19 | const appPreferences = useSelector(selectAppPreferences(appId)); 20 | const mobileView = useIsOnMobile(); 21 | const NOTIFICATION_CHANNELS = notificationChannels(); 22 | const hideAppPreferences = shouldHideAppPreferences(appPreferences, appId) || false; 23 | 24 | return ( 25 | !hideAppPreferences && ( 26 | 31 | 32 |
    33 | 34 | {intl.formatMessage(messages.notificationAppTitle, { key: appId })} 35 | 36 |
    37 |
    38 | 39 |
    40 | 41 | {!mobileView && ( 42 |
    43 | {Object.values(NOTIFICATION_CHANNELS).map((channel) => ( 44 | 45 | ))} 46 |
    47 | )} 48 |
    49 | {mobileView &&
    } 50 |
    51 |
    52 | ) 53 | ); 54 | }; 55 | 56 | NotificationPreferenceApp.propTypes = { 57 | appId: PropTypes.string.isRequired, 58 | }; 59 | 60 | export default React.memo(NotificationPreferenceApp); 61 | -------------------------------------------------------------------------------- /src/notification-preferences/NotificationPreferences.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import classNames from 'classnames'; 5 | 6 | import { useIntl } from '@edx/frontend-platform/i18n'; 7 | import { Spinner, NavItem } from '@openedx/paragon'; 8 | 9 | import { useIsOnMobile } from '../hooks'; 10 | import messages from './messages'; 11 | import NotificationPreferenceApp from './NotificationPreferenceApp'; 12 | import { fetchCourseNotificationPreferences } from './data/thunks'; 13 | import { LOADING_STATUS } from '../constants'; 14 | import { 15 | selectCourseListStatus, selectNotificationPreferencesStatus, selectPreferenceAppsId, selectSelectedCourseId, 16 | } from './data/selectors'; 17 | import { notificationChannels } from './data/utils'; 18 | 19 | const NotificationPreferences = () => { 20 | const dispatch = useDispatch(); 21 | const intl = useIntl(); 22 | const courseStatus = useSelector(selectCourseListStatus()); 23 | const courseId = useSelector(selectSelectedCourseId()); 24 | const notificationStatus = useSelector(selectNotificationPreferencesStatus()); 25 | const preferenceAppsIds = useSelector(selectPreferenceAppsId()); 26 | const mobileView = useIsOnMobile(); 27 | const NOTIFICATION_CHANNELS = notificationChannels(); 28 | const isLoading = notificationStatus === LOADING_STATUS || courseStatus === LOADING_STATUS; 29 | 30 | const preferencesList = useMemo(() => ( 31 | preferenceAppsIds.map(appId => ( 32 | 33 | )) 34 | ), [preferenceAppsIds]); 35 | 36 | useEffect(() => { 37 | dispatch(fetchCourseNotificationPreferences(courseId)); 38 | }, [courseId, dispatch]); 39 | 40 | if (preferenceAppsIds.length === 0) { 41 | return null; 42 | } 43 | 44 | return ( 45 |
    46 | {!mobileView && !isLoading && ( 47 |
    48 |
    49 | {Object.values(NOTIFICATION_CHANNELS).map((channel) => ( 50 |
    51 | 61 | {intl.formatMessage(messages.notificationChannel, { text: channel })} 62 | 63 |
    64 | ))} 65 |
    66 |
    67 | )} 68 | {preferencesList} 69 | {isLoading && ( 70 |
    71 | 78 |
    79 | )} 80 |
    81 | ); 82 | }; 83 | 84 | export default NotificationPreferences; 85 | -------------------------------------------------------------------------------- /src/notification-preferences/NotificationSettings.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | 4 | import { useIntl } from '@edx/frontend-platform/i18n'; 5 | import { Container, Hyperlink } from '@openedx/paragon'; 6 | 7 | import { selectSelectedCourseId, selectShowPreferences } from './data/selectors'; 8 | import messages from './messages'; 9 | import NotificationCoursesDropdown from './NotificationCoursesDropdown'; 10 | import NotificationPreferences from './NotificationPreferences'; 11 | import { useFeedbackWrapper } from '../hooks'; 12 | 13 | const NotificationSettings = () => { 14 | useFeedbackWrapper(); 15 | const intl = useIntl(); 16 | const showPreferences = useSelector(selectShowPreferences()); 17 | const courseId = useSelector(selectSelectedCourseId()); 18 | 19 | return ( 20 | showPreferences && ( 21 | 22 |

    23 | {intl.formatMessage(messages.notificationHeading)} 24 |

    25 |
    26 | {intl.formatMessage(messages.accountNotificationDescription)} 27 |
    28 |
    29 | {intl.formatMessage(messages.notificationCadenceDescription, { 30 | dailyTime: '22:00 UTC', 31 | weeklyTime: '22:00 UTC Every Sunday', 32 | })} 33 |
    34 |
    35 | {intl.formatMessage(messages.notificationPreferenceGuideBody)} 36 | 42 | {intl.formatMessage(messages.notificationPreferenceGuideLink)} 43 | 44 |
    45 | 46 | 47 |
    48 | 49 | ) 50 | ); 51 | }; 52 | 53 | export default NotificationSettings; 54 | -------------------------------------------------------------------------------- /src/notification-preferences/NotificationTypes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | import { InfoOutline } from '@openedx/paragon/icons'; 7 | import { useIntl } from '@edx/frontend-platform/i18n'; 8 | import { Icon, OverlayTrigger, Tooltip } from '@openedx/paragon'; 9 | 10 | import messages from './messages'; 11 | import { useIsOnMobile } from '../hooks'; 12 | import { notificationChannels } from './data/utils'; 13 | 14 | import { selectAppPreferences } from './data/selectors'; 15 | import NotificationPreferenceColumn from './NotificationPreferenceColumn'; 16 | 17 | const NotificationTypes = ({ appId }) => { 18 | const intl = useIntl(); 19 | const preferences = useSelector(selectAppPreferences(appId)); 20 | const mobileView = useIsOnMobile(); 21 | const NOTIFICATION_CHANNELS = notificationChannels(); 22 | 23 | return ( 24 |
    25 | {preferences.map(preference => ( 26 | (preference?.coreNotificationTypes?.length > 0 || preference.id !== 'core') && ( 27 | <> 28 |
    33 | {intl.formatMessage(messages.notificationTitle, { text: preference.id })} 34 | {preference.info !== '' && ( 35 | 41 | {preference.info} 42 | 43 | )} 44 | > 45 | 46 | 47 | 48 | 49 | )} 50 |
    51 | {mobileView && ( 52 |
    53 | {Object.values(NOTIFICATION_CHANNELS).map((channel) => ( 54 | 55 | ))} 56 |
    57 | )} 58 | 59 | ) 60 | 61 | ))} 62 |
    63 | ); 64 | }; 65 | 66 | NotificationTypes.propTypes = { 67 | appId: PropTypes.string.isRequired, 68 | }; 69 | 70 | export default React.memo(NotificationTypes); 71 | -------------------------------------------------------------------------------- /src/notification-preferences/ToggleSwitch.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PropTypes from 'prop-types'; 4 | 5 | import { Form } from '@openedx/paragon'; 6 | 7 | const ToggleSwitch = ({ 8 | name, 9 | value, 10 | disabled, 11 | onChange, 12 | id, 13 | className, 14 | }) => ( 15 | 23 | ); 24 | 25 | ToggleSwitch.propTypes = { 26 | name: PropTypes.string.isRequired, 27 | value: PropTypes.bool.isRequired, 28 | disabled: PropTypes.bool, 29 | onChange: PropTypes.func, 30 | id: PropTypes.string, 31 | className: PropTypes.string, 32 | }; 33 | 34 | ToggleSwitch.defaultProps = { 35 | onChange: () => null, 36 | disabled: false, 37 | id: '', 38 | className: '', 39 | }; 40 | 41 | export default React.memo(ToggleSwitch); 42 | -------------------------------------------------------------------------------- /src/notification-preferences/data/actions.js: -------------------------------------------------------------------------------- 1 | export const Actions = { 2 | FETCHED_PREFERENCES: 'fetchedPreferences', 3 | FETCHING_PREFERENCES: 'fetchingPreferences', 4 | FAILED_PREFERENCES: 'failedPreferences', 5 | FETCHING_COURSE_LIST: 'fetchingCourseList', 6 | FETCHED_COURSE_LIST: 'fetchedCourseList', 7 | FAILED_COURSE_LIST: 'failedCourseList', 8 | UPDATE_SELECTED_COURSE: 'updateSelectedCourse', 9 | UPDATE_PREFERENCE: 'updatePreference', 10 | UPDATE_APP_PREFERENCE: 'updateAppValue', 11 | }; 12 | 13 | export const fetchNotificationPreferenceSuccess = (courseId, payload, isAccountPreference) => dispatch => ( 14 | dispatch({ 15 | type: Actions.FETCHED_PREFERENCES, courseId, payload, isAccountPreference, 16 | }) 17 | ); 18 | 19 | export const fetchNotificationPreferenceFetching = () => dispatch => ( 20 | dispatch({ type: Actions.FETCHING_PREFERENCES }) 21 | ); 22 | 23 | export const fetchNotificationPreferenceFailed = () => dispatch => ( 24 | dispatch({ type: Actions.FAILED_PREFERENCES }) 25 | ); 26 | 27 | export const fetchCourseListSuccess = payload => dispatch => ( 28 | dispatch({ type: Actions.FETCHED_COURSE_LIST, payload }) 29 | ); 30 | 31 | export const fetchCourseListFetching = () => dispatch => ( 32 | dispatch({ type: Actions.FETCHING_COURSE_LIST }) 33 | ); 34 | 35 | export const fetchCourseListFailed = () => dispatch => ( 36 | dispatch({ type: Actions.FAILED_COURSE_LIST }) 37 | ); 38 | 39 | export const updateSelectedCourse = courseId => dispatch => ( 40 | dispatch({ type: Actions.UPDATE_SELECTED_COURSE, courseId }) 41 | ); 42 | 43 | export const updatePreferenceValue = (appId, preferenceName, notificationChannel, value) => dispatch => ( 44 | dispatch({ 45 | type: Actions.UPDATE_PREFERENCE, 46 | appId, 47 | preferenceName, 48 | notificationChannel, 49 | value, 50 | }) 51 | ); 52 | -------------------------------------------------------------------------------- /src/notification-preferences/data/constants.js: -------------------------------------------------------------------------------- 1 | export const EMAIL_CADENCE_PREFERENCES = { 2 | DAILY: 'Daily', 3 | WEEKLY: 'Weekly', 4 | }; 5 | export const EMAIL_CADENCE = 'email_cadence'; 6 | export const EMAIL = 'email'; 7 | export const MIXED = 'Mixed'; 8 | export const RequestStatus = /** @type {const} */ ({ 9 | IN_PROGRESS: 'in-progress', 10 | SUCCESSFUL: 'successful', 11 | FAILED: 'failed', 12 | DENIED: 'denied', 13 | PENDING: 'pending', 14 | CLEAR: 'clear', 15 | PARTIAL: 'partial', 16 | PARTIAL_FAILURE: 'partial failure', 17 | NOT_FOUND: 'not-found', 18 | }); 19 | -------------------------------------------------------------------------------- /src/notification-preferences/data/selectors.js: -------------------------------------------------------------------------------- 1 | export const selectNotificationPreferencesStatus = () => state => ( 2 | state.notificationPreferences.preferences.status 3 | ); 4 | 5 | export const selectUpdatePreferencesStatus = () => state => ( 6 | state.notificationPreferences.preferences.updatePreferenceStatus 7 | ); 8 | 9 | export const selectPreferences = () => state => ( 10 | state.notificationPreferences.preferences?.preferences 11 | ); 12 | 13 | export const selectCourseListStatus = () => state => ( 14 | state.notificationPreferences.courses.status 15 | ); 16 | 17 | export const selectCourseList = () => state => ( 18 | state.notificationPreferences.courses.courses 19 | ); 20 | 21 | export const selectCourse = courseId => state => ( 22 | selectCourseList()(state).find( 23 | course => course.id === courseId, 24 | ) 25 | ); 26 | 27 | export const selectPreferenceAppsId = () => state => ( 28 | state.notificationPreferences.preferences.apps.map(app => app.id) 29 | ); 30 | 31 | export const selectAppPreferences = appId => state => ( 32 | selectPreferences()(state).filter(preference => ( 33 | preference.appId === appId 34 | )) 35 | ); 36 | 37 | export const selectPreferenceApp = appId => state => ( 38 | state.notificationPreferences.preferences.apps.find(app => ( 39 | app.id === appId 40 | )) 41 | ); 42 | 43 | export const selectPreferenceAppToggleValue = appId => state => ( 44 | selectPreferenceApp(appId)(state).enabled 45 | ); 46 | 47 | export const selectPreference = (appId, name) => state => ( 48 | selectPreferences()(state).find((preference) => ( 49 | preference.id === name && preference.appId === appId 50 | )) 51 | ); 52 | 53 | export const selectPreferenceNonEditableChannels = (appId, name) => state => ( 54 | state?.notificationPreferences.preferences.nonEditable[appId]?.[name] || [] 55 | ); 56 | 57 | export const selectSelectedCourseId = () => state => ( 58 | state.notificationPreferences.preferences.selectedCourse 59 | ); 60 | 61 | export const selectPagination = () => state => ( 62 | state.notificationPreferences.courses.pagination 63 | ); 64 | 65 | export const selectShowPreferences = () => state => ( 66 | state.notificationPreferences.showPreferences 67 | ); 68 | -------------------------------------------------------------------------------- /src/notification-preferences/data/service.js: -------------------------------------------------------------------------------- 1 | import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; 2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 3 | import snakeCase from 'lodash.snakecase'; 4 | 5 | export const getCourseNotificationPreferences = async (courseId) => { 6 | const url = `${getConfig().LMS_BASE_URL}/api/notifications/configurations/${courseId}`; 7 | const { data } = await getAuthenticatedHttpClient().get(url); 8 | return data; 9 | }; 10 | 11 | export const getCourseList = async (page, pageSize) => { 12 | const params = snakeCaseObject({ page, pageSize }); 13 | const url = `${getConfig().LMS_BASE_URL}/api/notifications/enrollments/`; 14 | const { data } = await getAuthenticatedHttpClient().get(url, { params }); 15 | return data; 16 | }; 17 | 18 | export const patchPreferenceToggle = async ( 19 | courseId, 20 | notificationApp, 21 | notificationType, 22 | notificationChannel, 23 | value, 24 | ) => { 25 | const patchData = snakeCaseObject({ 26 | notificationApp, 27 | notificationType: snakeCase(notificationType), 28 | notificationChannel, 29 | value, 30 | }); 31 | const url = `${getConfig().LMS_BASE_URL}/api/notifications/configurations/${courseId}`; 32 | const { data } = await getAuthenticatedHttpClient().patch(url, patchData); 33 | return data; 34 | }; 35 | 36 | export const postPreferenceToggle = async ( 37 | notificationApp, 38 | notificationType, 39 | notificationChannel, 40 | value, 41 | emailCadence, 42 | ) => { 43 | const patchData = snakeCaseObject({ 44 | notificationApp, 45 | notificationType: snakeCase(notificationType), 46 | notificationChannel, 47 | value, 48 | emailCadence, 49 | }); 50 | const url = `${getConfig().LMS_BASE_URL}/api/notifications/preferences/update-all/`; 51 | const { data } = await getAuthenticatedHttpClient().post(url, patchData); 52 | return data; 53 | }; 54 | -------------------------------------------------------------------------------- /src/notification-preferences/data/utils.js: -------------------------------------------------------------------------------- 1 | import { getConfig } from '@edx/frontend-platform'; 2 | 3 | export const notificationChannels = () => ({ WEB: 'web', ...(getConfig().SHOW_EMAIL_CHANNEL === 'true' && { EMAIL: 'email' }) }); 4 | 5 | export const shouldHideAppPreferences = (preferences, appId) => { 6 | const appPreferences = preferences.filter(pref => pref.appId === appId); 7 | 8 | if (appPreferences.length !== 1) { 9 | return false; 10 | } 11 | 12 | const firstPreference = appPreferences[0]; 13 | 14 | return firstPreference?.id === 'core' && (!firstPreference.coreNotificationTypes?.length); 15 | }; 16 | -------------------------------------------------------------------------------- /src/plugin-slots/FooterSlot/README.md: -------------------------------------------------------------------------------- 1 | # Footer Slot 2 | 3 | ### Slot ID: `org.openedx.frontend.layout.footer.v1` 4 | 5 | ### Slot ID Aliases 6 | * `footer_slot` 7 | 8 | ## Description 9 | 10 | This slot is used to replace/modify/hide the footer. 11 | 12 | The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/). 13 | 14 | ## Example 15 | 16 | The following `env.config.jsx` will replace the default footer. 17 | 18 | ![Screenshot of Default Footer](./images/default_footer.png) 19 | 20 | with a simple custom footer 21 | 22 | ![Screenshot of Custom Footer](./images/custom_footer.png) 23 | 24 | ```jsx 25 | import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; 26 | 27 | const config = { 28 | pluginSlots: { 29 | 'org.openedx.frontend.layout.footer.v1': { 30 | plugins: [ 31 | { 32 | // Hide the default footer 33 | op: PLUGIN_OPERATIONS.Hide, 34 | widgetId: 'default_contents', 35 | }, 36 | { 37 | // Insert a custom footer 38 | op: PLUGIN_OPERATIONS.Insert, 39 | widget: { 40 | id: 'custom_footer', 41 | type: DIRECT_PLUGIN, 42 | RenderWidget: () => ( 43 |

    🦶

    44 | ), 45 | }, 46 | }, 47 | ] 48 | } 49 | }, 50 | } 51 | 52 | export default config; 53 | ``` 54 | -------------------------------------------------------------------------------- /src/plugin-slots/FooterSlot/images/custom_footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-app-account/d0eebfa0ea79d811425b195f54ce821ef9ab7508/src/plugin-slots/FooterSlot/images/custom_footer.png -------------------------------------------------------------------------------- /src/plugin-slots/FooterSlot/images/default_footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-app-account/d0eebfa0ea79d811425b195f54ce821ef9ab7508/src/plugin-slots/FooterSlot/images/default_footer.png -------------------------------------------------------------------------------- /src/plugin-slots/IdVerificationPageSlot/README.md: -------------------------------------------------------------------------------- 1 | # ID Verification Page Slot 2 | 3 | ### Slot ID: `org.openedx.frontend.account.id_verification_page.v1` 4 | 5 | ### Slot ID Aliases 6 | * `id_verification_page_plugin` 7 | 8 | ## Description 9 | 10 | This slot is used to replace/modify the IDV Page. 11 | 12 | The implementation of the `IdVerificationPageSlot` component lives in `src/plugin-slots/IdVerificationPageSlot/index.jsx`. 13 | 14 | ## Example 15 | 16 | The following `env.config.jsx` will replace the default IDV Page. 17 | 18 | ![Screenshot of Default IDV Page](./images/default_id-verification-page.png) 19 | 20 | ```jsx 21 | import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework'; 22 | 23 | const config = { 24 | pluginSlots: { 25 | 'org.openedx.frontend.account.id_verification_page.v1': { 26 | plugins: [ 27 | { 28 | // Insert a custom IDV Page 29 | op: PLUGIN_OPERATIONS.Insert, 30 | widget: { 31 | id: 'custom_id_verification_page', 32 | type: DIRECT_PLUGIN, 33 | RenderWidget: () => ( 34 |
    35 |

    This is the new IDV page

    36 | Go Home 37 |
    38 | ), 39 | }, 40 | }, 41 | ], 42 | }, 43 | }, 44 | }; 45 | 46 | export default config; 47 | 48 | ``` 49 | -------------------------------------------------------------------------------- /src/plugin-slots/IdVerificationPageSlot/images/custom_id-verification-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-app-account/d0eebfa0ea79d811425b195f54ce821ef9ab7508/src/plugin-slots/IdVerificationPageSlot/images/custom_id-verification-page.png -------------------------------------------------------------------------------- /src/plugin-slots/IdVerificationPageSlot/images/default_id-verification-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-app-account/d0eebfa0ea79d811425b195f54ce821ef9ab7508/src/plugin-slots/IdVerificationPageSlot/images/default_id-verification-page.png -------------------------------------------------------------------------------- /src/plugin-slots/IdVerificationPageSlot/index.jsx: -------------------------------------------------------------------------------- 1 | import { PluginSlot } from '@openedx/frontend-plugin-framework'; 2 | import IdVerificationPage from '../../id-verification'; 3 | 4 | const IdVerificationPageSlot = () => ( 5 | 9 | 10 | 11 | ); 12 | 13 | export default IdVerificationPageSlot; 14 | -------------------------------------------------------------------------------- /src/plugin-slots/README.md: -------------------------------------------------------------------------------- 1 | # `frontend-app-account` Plugin Slots 2 | 3 | * [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/) 4 | * [`org.openedx.frontend.account.id_verification_page.v1`](./IdVerificationPageSlot/) 5 | -------------------------------------------------------------------------------- /src/setupTest.js: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import 'regenerator-runtime/runtime'; 3 | import '@testing-library/jest-dom'; 4 | 5 | import MockedPluginSlot from './tests/MockedPluginSlot'; 6 | 7 | jest.mock('@openedx/frontend-plugin-framework', () => ({ 8 | ...jest.requireActual('@openedx/frontend-plugin-framework'), 9 | Plugin: () => 'Plugin', 10 | PluginSlot: MockedPluginSlot, 11 | })); 12 | -------------------------------------------------------------------------------- /src/tests/MockedPluginSlot.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const MockedPluginSlot = ({ children, id }) => ( 5 |
    6 | PluginSlot_{id} 7 | { children &&
    {children}
    } 8 |
    9 | ); 10 | 11 | MockedPluginSlot.displayName = 'PluginSlot'; 12 | 13 | MockedPluginSlot.propTypes = { 14 | children: PropTypes.oneOfType([ 15 | PropTypes.arrayOf(PropTypes.node), 16 | PropTypes.node, 17 | ]), 18 | id: PropTypes.string, 19 | }; 20 | 21 | MockedPluginSlot.defaultProps = { 22 | children: undefined, 23 | id: undefined, 24 | }; 25 | 26 | export default MockedPluginSlot; 27 | -------------------------------------------------------------------------------- /src/tests/MockedPluginSlot.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import MockedPluginSlot from './MockedPluginSlot'; 4 | 5 | describe('MockedPluginSlot', () => { 6 | it('renders mock plugin with "PluginSlot" text', () => { 7 | render(); 8 | 9 | const component = screen.getByText('PluginSlot_test_plugin'); 10 | expect(component).toBeInTheDocument(); 11 | }); 12 | 13 | it('renders as the slot children directly if there is content within', () => { 14 | render( 15 |
    16 | 17 | How much wood could a woodchuck chuck if a woodchuck could chuck wood? 18 | 19 |
    , 20 | ); 21 | 22 | const component = screen.getByRole('article'); 23 | expect(component).toBeInTheDocument(); 24 | const slot = component.querySelector('[data-testid="test_plugin"]'); 25 | expect(slot).toBeInTheDocument(); 26 | expect(slot).toHaveTextContent('PluginSlot_test_plugin'); 27 | // Check if the quote is a direct child of the MockedPluginSlot 28 | const quote = slot.querySelector('q'); 29 | expect(quote).toBeInTheDocument(); 30 | expect(quote).toHaveTextContent('How much wood could a woodchuck chuck if a woodchuck could chuck wood?'); 31 | expect(quote.getAttribute('role')).toBe('note'); 32 | }); 33 | 34 | it('renders mock plugin with a data-testid ', () => { 35 | render( 36 | 37 | I am selling these fine leather jackets. 38 | , 39 | ); 40 | 41 | const component = screen.getByTestId('guybrush'); 42 | expect(component).toBeInTheDocument(); 43 | 44 | const quote = component.querySelector('[role=note]'); 45 | expect(quote).toBeInTheDocument(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/tests/hooks.test.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import { render, waitFor } from '@testing-library/react'; 4 | 5 | import { useAsyncCall } from '../hooks'; 6 | import { FAILURE_STATUS, LOADING_STATUS, SUCCESS_STATUS } from '../constants'; 7 | 8 | const TestUseAsyncCallHookComponent = ({ asyncFunc }) => { 9 | const { status, data } = useAsyncCall(asyncFunc); 10 | return ( 11 | <> 12 |
    {status}
    13 | {data && Object.keys(data).length !== 0 &&
    { data.data }
    } 14 | 15 | ); 16 | }; 17 | 18 | TestUseAsyncCallHookComponent.propTypes = { 19 | asyncFunc: PropTypes.func.isRequired, 20 | }; 21 | 22 | describe('useAsyncCall mock', () => { 23 | it('returns status and data correctly for successful response', async () => { 24 | const mockAsyncFunc = jest.fn(async () => ({ data: 'data' })); 25 | 26 | const { queryByText } = render(); 27 | 28 | await waitFor(() => (expect(mockAsyncFunc).toHaveBeenCalledTimes(1))); 29 | expect(queryByText(SUCCESS_STATUS)).not.toBeNull(); 30 | expect(queryByText('data')).not.toBeNull(); 31 | }); 32 | it('returns status and data correctly for unsuccessful response', async () => { 33 | const mockAsyncFunc = jest.fn(async () => ({})); 34 | 35 | const { queryByText, queryByTestId } = render(); 36 | 37 | await waitFor(() => (expect(mockAsyncFunc).toHaveBeenCalledTimes(1))); 38 | expect(queryByText(FAILURE_STATUS)).not.toBeNull(); 39 | expect(queryByTestId('data')).toBeNull(); 40 | }); 41 | it('returns status and data correctly for pending request', async () => { 42 | const mockAsyncFunc = jest.fn(async () => ({})); 43 | 44 | const { queryByText, queryByTestId } = render(); 45 | expect(queryByText(LOADING_STATUS)).not.toBeNull(); 46 | expect(queryByTestId('data')).toBeNull(); 47 | 48 | await waitFor(() => (expect(mockAsyncFunc).toHaveBeenCalledTimes(1))); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { compareVerifiedNamesByCreatedDate, getMostRecentApprovedOrPendingVerifiedName } from '../utils'; 2 | 3 | describe('getMostRecentApprovedOrPendingVerifiedName', () => { 4 | it('returns correct verified name if one exists', () => { 5 | const verifiedNames = [ 6 | { 7 | created: '2021-08-31T18:33:32.489200Z', 8 | verified_name: 'Mike', 9 | status: 'denied', 10 | }, 11 | { 12 | created: '2021-09-03T18:33:32.489200Z', 13 | verified_name: 'Michelangelo', 14 | status: 'approved', 15 | }, 16 | ]; 17 | 18 | expect(getMostRecentApprovedOrPendingVerifiedName(verifiedNames)).toEqual(verifiedNames[1].verified_name); 19 | }); 20 | it('returns no verified name if one does not exist', () => { 21 | const verifiedNames = [ 22 | { 23 | created: '2021-08-31T18:33:32.489200Z', 24 | verified_name: 'Mike', 25 | status: 'denied', 26 | }, 27 | { 28 | created: '2021-09-03T18:33:32.489200Z', 29 | verified_name: 'Michelangelo', 30 | status: 'submitted', 31 | }, 32 | ]; 33 | 34 | expect(getMostRecentApprovedOrPendingVerifiedName(verifiedNames)).toBeNull(); 35 | }); 36 | }); 37 | 38 | describe('compareVerifiedNamesByCreatedDate', () => { 39 | it('returns 0 when equal', () => { 40 | const a = { 41 | created: '2021-08-31T18:33:32.489200Z', 42 | verified_name: 'Mike', 43 | status: 'denied', 44 | }; 45 | const b = { 46 | created: '2021-08-31T18:33:32.489200Z', 47 | verified_name: 'Michael', 48 | status: 'denied', 49 | }; 50 | 51 | expect(compareVerifiedNamesByCreatedDate(a, b)).toEqual(0); 52 | }); 53 | 54 | it('returns negative number when first argument is greater than second argument', () => { 55 | const a = { 56 | created: '2021-09-30T18:33:32.489200Z', 57 | verified_name: 'Mike', 58 | status: 'denied', 59 | }; 60 | const b = { 61 | created: '2021-08-31T18:33:32.489200Z', 62 | verified_name: 'Michael', 63 | status: 'denied', 64 | }; 65 | 66 | expect(compareVerifiedNamesByCreatedDate(a, b)).toBeLessThan(0); 67 | }); 68 | 69 | it('returns positive number when first argument is less than second argument', () => { 70 | const a = { 71 | created: '2021-08-31T18:33:32.489200Z', 72 | verified_name: 'Mike', 73 | status: 'denied', 74 | }; 75 | const b = { 76 | created: '2021-09-30T18:33:32.489200Z', 77 | verified_name: 'Michael', 78 | status: 'denied', 79 | }; 80 | 81 | expect(compareVerifiedNamesByCreatedDate(a, b)).toBeGreaterThan(0); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Compare two dates. 3 | * @param {*} a the first date 4 | * @param {*} b the second date 5 | * @returns a negative integer if a > b, a positive integer if a < b, or 0 if a = b 6 | */ 7 | export function compareVerifiedNamesByCreatedDate(a, b) { 8 | const aTimeSinceEpoch = new Date(a.created).getTime(); 9 | const bTimeSinceEpoch = new Date(b.created).getTime(); 10 | return bTimeSinceEpoch - aTimeSinceEpoch; 11 | } 12 | 13 | /** 14 | * 15 | * @param {*} verifiedNames a list of verified name objects, where each object has at least the 16 | * following keys: created, status, and verified_name. 17 | * @returns the most recent verified name object from the list parameter with the 'pending' or 18 | * 'accepted' status, if one exists; otherwise, null 19 | */ 20 | export function getMostRecentApprovedOrPendingVerifiedName(verifiedNames) { 21 | // clone array so as not to modify original array 22 | const names = [...verifiedNames]; 23 | 24 | if (Array.isArray(names)) { 25 | names.sort(compareVerifiedNamesByCreatedDate); 26 | } 27 | 28 | // We only want to consider a subset of verified names when determining the value of nameOnAccount. 29 | // approved: consider this status, as the name has been verified by IDV and should supersede the full name 30 | // (profile name). 31 | // pending: consider this status, as the learner has started the name change process through the 32 | // Account Settings page, and has been navigated to IDV to complete the name change process. 33 | // submitted: do not consider this status, as the name has already been submitted for verification through 34 | // IDV but has not yet been verified 35 | // denied: do not consider this status because the name was already denied via the IDV process 36 | const applicableNames = names.filter(name => ['approved', 'pending'].includes(name.status)); 37 | const applicableName = applicableNames.length > 0 ? applicableNames[0].verified_name : null; 38 | 39 | return applicableName; 40 | } 41 | --------------------------------------------------------------------------------