Compare commits

..

3 Commits

Author SHA1 Message Date
renovate[bot]
ef4056cff2 fix(deps): update dependency @edx/frontend-platform to v1.9.5 (#190)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2021-05-07 14:07:07 -04:00
David Joy
58ea5ef292 fix: Resolving i18n violations, hard-coded edx.org-specific strings (#433)
* fix: removing hard-coded edX from DemographicsSection messages

* fix: removing hard-coded “edX” strings from IDV messages

* fix: updating ThirdPartyAuth message description to remove hard-coded “edX” references.

* fix: replacing hard-coded “edX” strings with SITE_NAME in AccountSettingsPage

* fix: conditionalizing edx-specific strings in ConfirmationModal

If the SITE_NAME is ‘edX’, then edx.org-specific strings will be used.  Otherwise, more general, Open edX-appropriate strings will be used.

* fix: conditionalizing edX-specific strings in delete account components.

If the SITE_NAME is ‘edX’, then edx.org-specific strings will be used.  Otherwise, more general, Open edX-appropriate strings will be used.

* fix: replacing hard-coded ‘edX’ strings with SITE_NAME in ReviewRequirementsPanel

I missed a few because the messages were re-used.

* fix: review feedback, improving messages

- Removing unnecessary {siteName} references
- Improving some message descriptions
2021-05-07 14:01:00 -04:00
David Joy
58affb83a9 fix: remove hard-coded edX from page title (#432)
This uses variable substitution to insert SITE_NAME into index.html, rather than hard-coding “edX” into the file.  It also updates the .env files to use “localhost” as the default SITE_NAME.
2021-05-03 15:53:05 -04:00
96 changed files with 13729 additions and 33985 deletions

52
.env
View File

@@ -1,29 +1,25 @@
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
COACHING_ENABLED=''
CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH=''
DEMOGRAPHICS_BASE_URL=''
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_DEMOGRAPHICS_COLLECTION=''
FAVICON_URL=''
LANGUAGE_PREFERENCE_COOKIE_NAME=''
LMS_BASE_URL=''
LOGIN_URL=''
LOGO_TRADEMARK_URL=''
LOGO_URL=''
LOGO_WHITE_URL=''
LOGOUT_URL=''
MARKETING_SITE_BASE_URL=''
NODE_ENV=''
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
DEMOGRAPHICS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=null
NODE_ENV=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=''
STUDIO_BASE_URL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
ENABLE_COPPA_COMPLIANCE=''
MARKETING_EMAILS_OPT_IN=''
SUPPORT_URL=null
USER_INFO_COOKIE_NAME=null
LOGO_URL=''
LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL=''
FAVICON_URL=''
PUBLISHER_BASE_URL=
STUDIO_BASE_URL=
DISCOVERY_API_BASE_URL=

View File

@@ -1,30 +1,29 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1997'
COACHING_ENABLED=''
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_DEMOGRAPHICS_COLLECTION=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:5335'
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
NODE_ENV='development'
ORDER_HISTORY_URL='http://localhost:1996/orders'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=1997
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SEGMENT_KEY=null
SITE_NAME=localhost
STUDIO_BASE_URL=''
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_COPPA_COMPLIANCE=''
MARKETING_EMAILS_OPT_IN=''
# Temporary, Remove this once we are ready to release the feature.
COACHING_ENABLED=true
ENABLE_DEMOGRAPHICS_COLLECTION=true
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
PUBLISHER_BASE_URL=
STUDIO_BASE_URL=
DISCOVERY_API_BASE_URL=

View File

@@ -1,29 +1,27 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1997'
COACHING_ENABLED=''
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_DEMOGRAPHICS_COLLECTION=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:5335'
NODE_ENV=''
ORDER_HISTORY_URL='http://localhost:1996/orders'
PUBLISHER_BASE_URL=''
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
NODE_ENV=null
ORDER_HISTORY_URL='localhost:1996/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SEGMENT_KEY=null
SITE_NAME=localhost
STUDIO_BASE_URL=''
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_COPPA_COMPLIANCE=''
MARKETING_EMAILS_OPT_IN=''
COACHING_ENABLED=''
ENABLE_DEMOGRAPHICS_COLLECTION=''
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
PUBLISHER_BASE_URL=
STUDIO_BASE_URL=
DISCOVERY_API_BASE_URL=

View File

@@ -1,19 +0,0 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,38 +0,0 @@
name: ci
on:
push:
branches:
- master
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [12, 14, 16]
npm: [6, 8.5.0]
npm-test:
- i18n_extract
- is-es5
- lint
- test
exclude:
- node: 12
npm: 8.5.0
- node: 14
npm: 8.5.0
- node: 16
npm: 6
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- run: npm install -g npm@${{ matrix.npm }}
- run: make requirements
- run: make test NPM_TESTS=build
- run: make test NPM_TESTS=${{ matrix.npm-test }}
- name: upload coverage
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: false

View File

@@ -1,10 +0,0 @@
# Run commitlint on the commit messages in a pull request.
name: Lint Commit Messages
on:
- pull_request
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master

15
.travis.yml Executable file
View File

@@ -0,0 +1,15 @@
language: node_js
node_js: 12
before_install:
- npm install -g npm@6
install:
- npm ci
script:
- make validate-no-uncommitted-package-lock-changes
- npm run i18n_extract
- npm run lint
- npm run test
- npm run build
- npm run is-es5
after_success:
- codecov

View File

@@ -1,9 +1,8 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-account]
[edx-platform.frontend-app-account]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON
type = KEYVALUEJSON

View File

@@ -1,4 +1,3 @@
export TRANSIFEX_RESOURCE = frontend-app-account
transifex_resource = frontend-app-account
transifex_langs = "ar,fr,es_419,zh_CN"
@@ -11,19 +10,8 @@ tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transi
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test is-es5
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
.PHONY: test.npm.*
test.npm.%: validate-no-uncommitted-package-lock-changes
test -d node_modules || $(MAKE) requirements
npm run $(*)
.PHONY: requirements
requirements: ## install ci requirements
npm ci
requirements:
npm install
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -46,15 +34,15 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,110 +1,48 @@
|ci-badge| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
frontend-app-account
====================
Please tag **@edx/community-engineering** on any PRs or issues. Thanks!
This is a micro-frontend application responsible for the display and updating of a user's account information. Please tag **@edx/arch-team** on any PRs or issues.
Introduction
------------
Development
-----------
This is a micro-frontend application responsible for the display and updating of a user's account information.
Start Devstack
^^^^^^^^^^^^^^
What is the domain of this MFE?
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
In this MFE: Private user settings UIs. Public facing profile is in a `separate MFE (Profile) <https://github.com/edx/frontend-app-profile>`_
- Start devstack
- Log in (http://localhost:18000/login)
- Account settings page
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- Demographics collection
In this project, install requirements and start the development server by running:
- IDV (Identity Verification)
.. code:: bash
Installation
------------
npm install
npm start # The server will run on port 1997
This MFE is bundled with `Devstack <https://github.com/edx/devstack>`_, see the `Getting Started <https://github.com/edx/devstack#getting-started>`_ section for setup instructions.
Once the dev server is up visit http://localhost:1997.
1. Install Devstack using the `Getting Started <https://github.com/edx/devstack#getting-started>`_ instructions.
Configuration and Deployment
----------------------------
2. Start up Devstack, if it's not already started.
3. Log in to Devstack (http://localhost:18000/login )
4. Within this project, install requirements and start the development server:
.. code-block::
npm install
npm start # The server will run on port 1997
5. Once the dev server is up, visit http://localhost:1997 to access the MFE
.. image:: ./docs/images/localhost_preview.png
Environment Variables/Setup Notes
---------------------------------
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
The account settings micro-frontend also supports the following additional variable:
``SUPPORT_URL``
Example: ``https://support.example.com``
The fully-qualified URL to the support page in the target environment.
edX-specific Environment Variables
**********************************
Furthermore, there are several edX-specific environment variables that enable integrations with closed-source services private to the edX organization, and are unsupported in Open edX. Enabling these environment variables will result in undefined behavior in Open edX installations:
``COACHING_ENABLED``
Example: ``true`` | ``''`` (empty strings are falsy)
Enables support for a section of the micro-frontend that helps users arrange for coaching sessions. Integrates with a private coaching plugin and is only used by edx.org.
``ENABLE_DEMOGRAPHICS_COLLECTION``
Example: ``true`` | ``''`` (empty strings are falsy)
Enables support for a section of the account settings page where a user can enter demographics information. Integrates with a private demographics service and is only used by edx.org.
``DEMOGRAPHICS_BASE_URL``
Example: ``https://demographics.example.com``
Required only if ``ENABLE_DEMOGRAPHICS_COLLECTION`` is true. The fully-qualified URL to the private demographics service in the target environment.
Example build syntax with a single environment variable:
This MFE is configured via node environment variables supplied at build time. See the .env file for the list of required environment variables. Example build syntax with a single environment variable:
.. code:: bash
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
For more information see the document: `Micro-frontend applications in Open
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
edX <https://github.com/edx/edx-developer-docs/blob/5191e800bf16cf42f25c58c58f983bdaf7f9305d/docs/micro-frontends-in-open-edx.rst>`__.
Known Issues
------------
None
Development Roadmap
-------------------
We don't have anything planned for the core of the MFE (the account settings page) - this MFE is currently in maintenance mode.
There may be a replacement for IDV coming down the pipe, so that may be DEPRed.
In the future, it's possible that demographics could be modeled as a plugin rather than being hard-coded into this MFE.
==============================
.. |ci-badge| image:: https://github.com/edx/edx-developer-docs/actions/workflows/ci.yml/badge.svg
:target: https://github.com/edx/edx-developer-docs/actions/workflows/ci.yml
:alt: Continuous Integration
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-account.svg?branch=master
:target: https://travis-ci.com/edx/frontend-app-account
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
:target: https://codecov.io/gh/edx/frontend-app-account
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -1,7 +1,7 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
setupFiles: [
'<rootDir>/src/setupTest.js',
],
});

View File

@@ -3,4 +3,5 @@
nick: acct
oeps: {}
owner: edx/arch-team
openedx-release: {ref: master}

41907
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,27 +30,26 @@
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.2.0",
"@edx/frontend-component-header": "2.4.5",
"@edx/frontend-platform": "1.15.1",
"@edx/paragon": "19.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.16",
"@edx/frontend-component-footer": "10.1.4",
"@edx/frontend-component-header": "2.2.4",
"@edx/frontend-platform": "1.9.5",
"@edx/paragon": "13.1.2",
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.8.2",
"@fortawesome/free-regular-svg-icons": "5.7.2",
"@fortawesome/free-solid-svg-icons": "5.8.2",
"@fortawesome/react-fontawesome": "0.1.14",
"@tensorflow-models/blazeface": "0.0.7",
"@tensorflow/tfjs-converter": "1.7.4",
"@tensorflow/tfjs-core": "1.7.4",
"bowser": "2.11.0",
"classnames": "2.3.1",
"core-js": "3.19.3",
"@tensorflow/tfjs-converter": "1.6.1",
"@tensorflow/tfjs-core": "1.6.1",
"babel-polyfill": "6.26.0",
"bowser": "2.10.0",
"classnames": "2.2.6",
"font-awesome": "4.7.0",
"form-urlencoded": "4.0.1",
"formdata-polyfill": "4.0.10",
"formdata-polyfill": "3.0.20",
"history": "4.10.1",
"jslib-html5-camera-photo": "3.1.8",
"lodash.camelcase": "4.3.0",
"jslib-html5-camera-photo": "3.1.6",
"lodash.debounce": "4.0.8",
"lodash.findindex": "4.6.0",
"lodash.get": "4.4.2",
@@ -59,36 +58,36 @@
"lodash.omit": "4.5.0",
"lodash.pick": "4.4.0",
"lodash.pickby": "4.6.0",
"memoize-one": "5.2.1",
"memoize-one": "5.1.1",
"newrelic": "5.13.1",
"prop-types": "15.7.2",
"qs": "6.10.3",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.6",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"qs": "6.9.6",
"react": "16.10.2",
"react-dom": "16.10.2",
"react-redux": "7.1.3",
"react-router": "5.1.2",
"react-router-dom": "5.1.2",
"react-router-hash-link": "1.2.2",
"react-scrollspy": "3.4.3",
"react-transition-group": "4.4.2",
"redux": "4.1.2",
"redux-devtools-extension": "2.13.9",
"react-transition-group": "4.3.0",
"redux": "4.0.5",
"redux-devtools-extension": "2.13.8",
"redux-logger": "3.0.6",
"redux-saga": "1.1.3",
"redux-thunk": "2.3.0",
"regenerator-runtime": "0.13.9",
"reselect": "4.0.0",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@edx/frontend-build": "9.1.1",
"@edx/reactifex": "^1.0.3",
"@testing-library/jest-dom": "5.15.1",
"@testing-library/react": "12.1.4",
"codecov": "3.8.3",
"enzyme": "3.11.0",
"@edx/frontend-build": "5.6.9",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "10.4.9",
"codecov": "3.7.2",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.6",
"es-check": "6.1.1",
"react-test-renderer": "16.14.0",
"es-check": "5.0.0",
"husky": "3.0.9",
"react-test-renderer": "16.8.6",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.4"
}

View File

@@ -4,7 +4,7 @@
<title>Account | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<link rel="shortcut icon" href="<%=webpackConfig.output.publicPath%>favicon.ico" type="image/x-icon" />
<% if (process.env.OPTIMIZELY_PROJECT_ID) { %>
<script
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"

View File

@@ -1,28 +1,17 @@
{
"extends": [
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
"config:base"
],
"patch": {
"automerge": true
},
"rebaseStalePrs": true,
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
"matchPackagePatterns": ["^@edx/paragon"],
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true,
"stabilityDays": 3
}
],
"timezone": "America/New_York"
]
}

View File

@@ -13,30 +13,20 @@ import {
getCountryList,
getLanguageList,
} from '@edx/frontend-platform/i18n';
import {
Button, Hyperlink, Icon, Alert,
} from '@edx/paragon';
import { CheckCircle, Error, WarningFilled } from '@edx/paragon/icons';
import { Hyperlink } from '@edx/paragon';
import messages from './AccountSettingsPage.messages';
import {
fetchSettings,
saveMultipleSettings,
saveSettings,
updateDraft,
beginNameChange,
} from './data/actions';
import { fetchSettings, saveSettings, updateDraft } from './data/actions';
import { accountSettingsPageSelector } from './data/selectors';
import PageLoading from './PageLoading';
import Alert from './Alert';
import JumpNav from './JumpNav';
import DeleteAccount from './delete-account';
import EditableField from './EditableField';
import ResetPassword from './reset-password';
import NameChange from './name-change';
import ThirdPartyAuth from './third-party-auth';
import BetaLanguageBanner from './BetaLanguageBanner';
import EmailField from './EmailField';
import OneTimeDismissibleAlert from './OneTimeDismissibleAlert';
import {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
@@ -146,57 +136,14 @@ class AccountSettingsPage extends React.Component {
})),
}));
sortDates = (a, b) => {
const aTimeSinceEpoch = new Date(a).getTime();
const bTimeSinceEpoch = new Date(b).getTime();
return bTimeSinceEpoch - aTimeSinceEpoch;
}
sortVerifiedNameRecords = verifiedNameHistory => {
if (Array.isArray(verifiedNameHistory)) {
return [...verifiedNameHistory].sort(this.sortDates);
}
return [];
}
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
}
};
handleSubmit = (formId, values) => {
this.props.saveSettings(formId, values);
}
handleSubmitProfileName = (formId, values) => {
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
this.props.saveMultipleSettings([
{
formId,
commitValues: values,
},
{
formId: 'useVerifiedNameForCerts',
commitValues: this.props.formValues.useVerifiedNameForCerts,
},
], formId);
} else {
this.props.saveSettings(formId, values);
}
};
handleSubmitVerifiedName = (formId, values) => {
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
this.props.saveSettings('useVerifiedNameForCerts', this.props.formValues.useVerifiedNameForCerts);
}
if (values !== this.props.committedValues?.verified_name) {
this.props.beginNameChange(formId);
} else {
this.props.saveSettings(formId, values);
}
}
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
}
@@ -214,7 +161,7 @@ class AccountSettingsPage extends React.Component {
return (
<div>
<Alert variant="danger">
<Alert className="alert alert-danger" role="alert">
<FormattedMessage
id="account.settings.message.duplicate.tpa.provider"
defaultMessage="The {provider} account you selected is already linked to another {siteName} account."
@@ -236,7 +183,7 @@ class AccountSettingsPage extends React.Component {
return (
<div>
<Alert variant="info">
<Alert className="alert alert-primary" role="alert">
<FormattedMessage
id="account.settings.message.managed.settings"
defaultMessage="Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help."
@@ -259,170 +206,6 @@ class AccountSettingsPage extends React.Component {
);
}
renderFullNameHelpText = (status, proctoredExamId) => {
if (!this.props.verifiedNameHistory) {
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text']);
}
let messageString = 'account.settings.field.full.name.help.text';
if (status === 'submitted') {
messageString += '.submitted';
if (proctoredExamId) {
messageString += '.proctored';
}
} else {
messageString += '.default';
}
if (!this.props.committedValues.useVerifiedNameForCerts) {
messageString += '.certificate';
}
return this.props.intl.formatMessage(messages[messageString]);
}
renderVerifiedNameSuccessMessage = (verifiedName, created) => {
const dateValue = new Date(created).valueOf();
const id = `dismissedVerifiedNameSuccessMessage-${verifiedName}-${dateValue}`;
return (
<OneTimeDismissibleAlert
id={id}
variant="success"
icon={CheckCircle}
header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message.header'])}
body={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message'])}
/>
);
}
renderVerifiedNameFailureMessage = (verifiedName, created) => {
const dateValue = new Date(created).valueOf();
const id = `dismissedVerifiedNameFailureMessage-${verifiedName}-${dateValue}`;
return (
<OneTimeDismissibleAlert
id={id}
variant="danger"
icon={Error}
header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.header'])}
body={
(
<>
<div className="d-flex flex-row">
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
</div>
<div className="d-flex flex-row-reverse mt-3">
<Button
variant="primary"
href="https://support.edx.org/hc/en-us/articles/360004381594-Why-was-my-ID-verification-denied"
>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.help.link'])}
</Button>{' '}
</div>
</>
)
}
/>
);
}
renderVerifiedNameSubmittedMessage = (willCertNameChange) => (
<Alert
variant="warning"
icon={WarningFilled}
>
<Alert.Heading>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.header'])}
</Alert.Heading>
<p>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message'])}{' '}
{
willCertNameChange
&& this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.certificate'])
}
</p>
</Alert>
)
renderVerifiedNameMessage = verifiedNameRecord => {
const {
created,
status,
profile_name: profileName,
verified_name: verifiedName,
proctored_exam_attempt_id: proctoredExamId,
} = verifiedNameRecord;
let willCertNameChange = false;
if (
(
// User submitted a profile name change, and uses their profile name on certificates
this.props.committedValues.name !== profileName
&& !this.props.committedValues.useVerifiedNameForCerts
)
|| (
// User submitted a verified name change, and uses their verified name on certificates
this.props.committedValues.name === profileName
&& this.props.committedValues.useVerifiedNameForCerts
)
) {
willCertNameChange = true;
}
if (proctoredExamId) {
return null;
}
switch (status) {
case 'approved':
return this.renderVerifiedNameSuccessMessage(verifiedName, created);
case 'denied':
return this.renderVerifiedNameFailureMessage(verifiedName, created);
case 'submitted':
return this.renderVerifiedNameSubmittedMessage(willCertNameChange);
default:
return null;
}
}
renderVerifiedNameIcon = (status) => {
switch (status) {
case 'approved':
return (<Icon src={CheckCircle} className="ml-1" style={{ height: '18px', width: '18px', color: 'green' }} />);
case 'submitted':
return (<Icon src={WarningFilled} className="ml-1" style={{ height: '18px', width: '18px', color: 'yellow' }} />);
default:
return null;
}
}
renderVerifiedNameHelpText = (status, proctoredExamId) => {
let messageStr = 'account.settings.field.name.verified.help.text';
// add additional string based on status
if (status === 'approved') {
messageStr += '.verified';
} else if (status === 'submitted') {
messageStr += '.submitted';
} else {
return null;
}
// add additional string if verified name came from a proctored exam attempt
if (proctoredExamId) {
messageStr += '.proctored';
}
// add additional string based on certificate name use
if (this.props.committedValues.useVerifiedNameForCerts) {
messageStr += '.certificate';
}
return this.props.intl.formatMessage(messages[messageStr]);
}
renderEmptyStaticFieldMessage() {
if (this.isManagedProfile()) {
return this.props.intl.formatMessage(messages['account.settings.static.field.empty'], {
@@ -432,13 +215,6 @@ class AccountSettingsPage extends React.Component {
return this.props.intl.formatMessage(messages['account.settings.static.field.empty.no.admin']);
}
renderNameChangeModal() {
if (this.props.nameChangeModal && this.props.nameChangeModal.formId) {
return <NameChange targetFormId={this.props.nameChangeModal.formId} />;
}
return null;
}
renderSecondaryEmailField(editableFieldProps) {
if (!this.props.formValues.secondary_email_enabled) {
return null;
@@ -485,8 +261,6 @@ class AccountSettingsPage extends React.Component {
// Show State field only if the country is US (could include Canada later)
const showState = this.props.formValues.country === COUNTRY_WITH_STATES;
const { verifiedName } = this.props;
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
this.props.timeZoneOptions,
this.props.countryTimeZoneOptions,
@@ -494,22 +268,16 @@ class AccountSettingsPage extends React.Component {
);
const hasLinkedTPA = findIndex(this.props.tpaProviders, provider => provider.connected) >= 0;
return (
<>
<div className="account-section" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
{
this.props.mostRecentVerifiedName
&& this.renderVerifiedNameMessage(this.props.mostRecentVerifiedName)
}
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
{this.renderManagedProfileMessage()}
{this.renderNameChangeModal()}
<EditableField
name="username"
type="text"
@@ -525,58 +293,17 @@ class AccountSettingsPage extends React.Component {
<EditableField
name="name"
type="text"
value={
verifiedName?.status === 'submitted'
&& this.props.formValues.pending_name_change
? this.props.formValues.pending_name_change
: this.props.formValues.name
}
value={this.props.formValues.name}
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
this.isEditable('name')
? this.props.intl.formatMessage(messages['account.settings.field.full.name.empty'])
: this.renderEmptyStaticFieldMessage()
}
helpText={
verifiedName
? this.renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
: this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])
}
isEditable={
verifiedName
? this.isEditable('verifiedName') && this.isEditable('name')
: this.isEditable('name')
}
isGrayedOut={
verifiedName && !this.isEditable('verifiedName')
}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitProfileName}
helpText={this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])}
isEditable={this.isEditable('name')}
{...editableFieldProps}
/>
{verifiedName
&& (
<EditableField
name="verified_name"
type="text"
value={this.props.formValues.verified_name}
label={
(
<div className="d-flex">
{this.props.intl.formatMessage(messages['account.settings.field.name.verified'])}
{
this.renderVerifiedNameIcon(verifiedName.status)
}
</div>
)
}
helpText={this.renderVerifiedNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)}
isEditable={this.isEditable('verifiedName')}
isGrayedOut={!this.isEditable('verifiedName')}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitVerifiedName}
/>
)}
<EmailField
name="email"
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
@@ -596,18 +323,15 @@ class AccountSettingsPage extends React.Component {
/>
{this.renderSecondaryEmailField(editableFieldProps)}
<ResetPassword email={this.props.formValues.email} />
{(!getConfig().ENABLE_COPPA_COMPLIANCE)
&& (
<EditableField
name="year_of_birth"
type="select"
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={this.props.formValues.year_of_birth}
options={yearOfBirthOptions}
{...editableFieldProps}
/>
)}
<EditableField
name="year_of_birth"
type="select"
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={this.props.formValues.year_of_birth}
options={yearOfBirthOptions}
{...editableFieldProps}
/>
<EditableField
name="country"
type="select"
@@ -842,7 +566,6 @@ AccountSettingsPage.propTypes = {
level_of_education: PropTypes.string,
gender: PropTypes.string,
language_proficiencies: PropTypes.string,
pending_name_change: PropTypes.string,
phone_number: PropTypes.string,
social_link_linkedin: PropTypes.string,
social_link_facebook: PropTypes.string,
@@ -855,18 +578,7 @@ AccountSettingsPage.propTypes = {
}),
state: PropTypes.string,
shouldDisplayDemographicsSection: PropTypes.bool,
useVerifiedNameForCerts: PropTypes.bool.isRequired,
verified_name: PropTypes.string,
}).isRequired,
committedValues: PropTypes.shape({
name: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string,
}),
drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({
name: PropTypes.string,
}),
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
draft: PropTypes.string,
@@ -890,45 +602,15 @@ AccountSettingsPage.propTypes = {
})),
fetchSiteLanguages: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
saveMultipleSettings: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
beginNameChange: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool,
})),
nameChangeModal: PropTypes.shape({
formId: PropTypes.string,
}),
verifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
mostRecentVerifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
verifiedNameHistory: PropTypes.arrayOf(
PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
),
tpaProviders: PropTypes.arrayOf(PropTypes.object),
};
AccountSettingsPage.defaultProps = {
loading: false,
loaded: false,
loadingError: null,
committedValues: {
useVerifiedNameForCerts: false,
verified_name: null,
},
drafts: {},
formErrors: {},
siteLanguage: null,
siteLanguageOptions: [],
timeZoneOptions: [],
@@ -938,17 +620,11 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {},
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
};
export default connect(accountSettingsPageSelector, {
fetchSettings,
saveSettings,
saveMultipleSettings,
updateDraft,
fetchSiteLanguages,
beginNameChange,
})(injectIntl(AccountSettingsPage));

View File

@@ -91,126 +91,6 @@ const messages = defineMessages({
defaultMessage: 'The name that is used for ID verification and that appears on your certificates.',
description: 'Help text for the account settings name field.',
},
'account.settings.field.full.name.help.text.default': {
id: 'account.settings.field.full.name.help.text.default',
defaultMessage: 'The name that appears on your public profile.',
description: 'Help text for the account settings name field.',
},
'account.settings.field.full.name.help.text.default.certificate': {
id: 'account.settings.field.full.name.help.text.default.certificate',
defaultMessage: 'This name is selected to appear on your certificates and public-facing records.',
description: 'Help text for the account settings name field.',
},
'account.settings.field.name.verified': {
id: 'account.settings.field.name.verified',
defaultMessage: 'Verified name',
description: 'Label for account settings verified name field.',
},
'account.settings.field.name.verified.help.text.verified': {
id: 'account.settings.field.name.verified.help.text.verified',
defaultMessage: 'This name has been verified by photo ID.',
description: 'Help text for the account settings verified name field when the name is verified.',
},
'account.settings.field.name.verified.help.text.verified.proctored': {
id: 'account.settings.field.name.verified.help.text.verified.proctored',
defaultMessage: 'This name has been verified by proctoring.',
description: 'Help text for the account settings verified name field when the name is verified through proctoring.',
},
'account.settings.field.name.verified.help.text.verified.certificate': {
id: 'account.settings.field.name.verified.help.text.verified.certificate',
defaultMessage: 'This name has been verified by photo ID, and is selected to appear on your certificates and public-facing records.',
description: 'Help text for the account settings verified name field when the name is selected for certificates.',
},
'account.settings.field.name.verified.help.text.verified.proctored.certificate': {
id: 'account.settings.field.name.verified.help.text.verified.proctored.certificate',
defaultMessage: 'This name has been verified by proctoring, and is selected to appear on your certificates and public-facing records.',
description: 'Help text for the account settings verified name field when the name is selected for certificates, and the name is verified through proctoring.',
},
'account.settings.field.name.verified.help.text.submitted': {
id: 'account.settings.field.name.verified.help.text.submitted',
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Verified name cannot be changed at this time.',
description: 'Help text for the account settings verified name field when a verified name has been submitted.',
},
'account.settings.field.name.verified.help.text.submitted.proctored': {
id: 'account.settings.field.name.verified.help.text.submitted.proctored',
defaultMessage: 'Your proctored exam has been submitted. Verified name cannot be changed at this time. Please check back in 2-5 days.',
description: 'Help text for the account settings verified name field when a verified name has been submitted through proctoring.',
},
'account.settings.field.name.verified.help.text.submitted.certificate': {
id: 'account.settings.field.name.verified.help.text.submitted.certificate',
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Verified name cannot be changed at this time.',
description: 'Help text for the account settings verified name field when a verified name has been submitted and will appear on certificates.',
},
'account.settings.field.name.verified.help.text.submitted.proctored.certificate': {
id: 'account.settings.field.name.verified.help.text.submitted.proctored.certificate',
defaultMessage: 'Once your proctored exam passes review, this name will appear on your certificate and public-facing records. Verified Name cannot be changed at this time.',
description: 'Help text for the account settings verified name field when a verified name has been submitted through proctoring and will appear on certificates.',
},
'account.settings.field.name.verified.verification.alert': {
id: 'account.settings.field.name.verified.verification.help',
defaultMessage: 'Enter your name as it appears on your unexpired student, work, or government-issued identification card.',
description: 'Form label instructing the user to enter the name on their ID.',
},
'account.settings.field.full.name.help.text.submitted': {
id: 'account.settings.field.full.name.help.text.submitted',
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Full name cannot be changed at this time.',
description: 'Help text for the account settings full name field when a verified name has been submitted.',
},
'account.settings.field.full.name.help.text.submitted.proctored': {
id: 'account.settings.field.full.name.help.text.submitted.proctored',
defaultMessage: 'Your proctored exam has been submitted. Full name cannot be changed at this time. Please check back in 2-5 days.',
description: 'Help text for the account settings full name field when a verified name has been submitted through proctoring.',
},
'account.settings.field.full.name.help.text.submitted.certificate': {
id: 'account.settings.field.full.name.help.text.submitted.certificate',
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',
description: 'Help text for the account settings full name field when a full name has been submitted and will appear on certificates.',
},
'account.settings.field.full.name.help.text.submitted.proctored.certificate': {
id: 'account.settings.field.full.name.help.text.submitted.proctored.certificate',
defaultMessage: 'Once your proctored exam passes review, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',
description: 'Help text for the account settings full name field when a full name has been submitted and will appear on certificates.',
},
'account.settings.field.name.verified.success.message': {
id: 'account.settings.field.name.verified.success.message',
defaultMessage: 'Your identity verification request has successfully completed. You now have the option of selecting which name you prefer to appear on your certificates and public-records.',
description: 'The body of the success alert indicating that a user\'s name has been verified',
},
'account.settings.field.name.verified.success.message.header': {
id: 'account.settings.field.name.verified.success.message.header',
defaultMessage: 'Your name change request is complete!',
description: 'The header of the success alert indicating that a user\'s name has been verified',
},
'account.settings.field.name.verified.failure.message': {
id: 'account.settings.field.name.verified.failure.message',
defaultMessage: 'Your most recent identity verification attempt did not pass. Related account settings have been restored.',
description: 'The body of the failure alert indicating that a user\'s name was not able to be verified',
},
'account.settings.field.name.verified.failure.message.header': {
id: 'account.settings.field.name.verified.failure.message.header',
defaultMessage: 'We were not able to verify your identity.',
description: 'The header of the failure alert indicating that a user\'s name was not able to be verified',
},
'account.settings.field.name.verified.failure.message.help.link': {
id: 'account.settings.field.name.verified.failure.message.help.link',
defaultMessage: 'Learn more about ID verification',
description: 'The text of the button displayed when a user\'s name was not able to be verified, intended to direct the user to a help article about ID verification.',
},
'account.settings.field.name.verified.submitted.message': {
id: 'account.settings.field.name.verified.submitted.message',
defaultMessage: 'Your identity verification request has been submitted and usually takes between 24 and 48 hours to complete.',
description: 'The body of the submitted alert indicating that a user\'s name has been submitted for verification',
},
'account.settings.field.name.verified.submitted.message.certificate': {
id: 'account.settings.field.name.verified.submitted.message.certificate',
defaultMessage: 'When your request is approved, your updated name will appear on all associated certificates and public-facing records.',
description: 'The body of the submitted alert indicating that a user\'s name will be updated on certificates.',
},
'account.settings.field.name.verified.submitted.message.header': {
id: 'account.settings.field.name.verified.submitted.message.header',
defaultMessage: 'Your name change request is almost complete!',
description: 'The header of the submitted alert indicating that a user\'s name has been submitted for verification',
},
'account.settings.field.email': {
id: 'account.settings.field.email',
defaultMessage: 'Email address (Sign in)',

View File

@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Input, StatefulButton, ValidationFormGroup,
@@ -17,7 +16,6 @@ import {
closeForm,
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
import CertificatePreference from './certificate-preference/CertificatePreference';
function EditableField(props) {
const {
@@ -39,15 +37,10 @@ function EditableField(props) {
onChange,
isEditing,
isEditable,
isGrayedOut,
intl,
...others
} = props;
const id = `field-${name}`;
let inputOptions = options;
if (getConfig().ENABLE_COPPA_COMPLIANCE && name === 'level_of_education' && options) {
inputOptions = options.filter(option => option.value !== 'el');
}
const handleSubmit = (e) => {
e.preventDefault();
@@ -109,57 +102,54 @@ function EditableField(props) {
expression={isEditing ? 'editing' : 'default'}
cases={{
editing: (
<>
<form onSubmit={handleSubmit}>
<ValidationFormGroup
for={id}
invalid={error != null}
invalidMessage={error}
helpText={helpText}
<form onSubmit={handleSubmit}>
<ValidationFormGroup
for={id}
invalid={error != null}
invalidMessage={error}
helpText={helpText}
>
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
data-hj-suppress
name={name}
id={id}
type={type}
value={value}
onChange={handleChange}
options={options}
{...others}
/>
<>{others.children}</>
</ValidationFormGroup>
<p>
<StatefulButton
type="submit"
className="mr-2"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (saveState === 'pending') { e.preventDefault(); }
}}
disabledStates={[]}
/>
<Button
variant="outline-primary"
onClick={handleCancel}
>
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
data-hj-suppress
name={name}
id={id}
type={type}
value={value}
onChange={handleChange}
options={inputOptions}
{...others}
/>
<>{others.children}</>
</ValidationFormGroup>
<p>
<StatefulButton
type="submit"
className="mr-2"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (saveState === 'pending') { e.preventDefault(); }
}}
disabledStates={[]}
/>
<Button
variant="outline-primary"
onClick={handleCancel}
>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
</>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
),
default: (
<div className="form-group">
@@ -171,7 +161,7 @@ function EditableField(props) {
</Button>
) : null}
</div>
<p data-hj-suppress className={isGrayedOut ? 'grayed-out' : null}>{renderValue(value)}</p>
<p data-hj-suppress>{renderValue(value)}</p>
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
</div>
),
@@ -182,7 +172,7 @@ function EditableField(props) {
EditableField.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
emptyLabel: PropTypes.node,
type: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
@@ -206,7 +196,6 @@ EditableField.propTypes = {
onChange: PropTypes.func.isRequired,
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
isGrayedOut: PropTypes.bool,
intl: intlShape.isRequired,
};
@@ -222,7 +211,6 @@ EditableField.defaultProps = {
helpText: undefined,
isEditing: false,
isEditable: true,
isGrayedOut: false,
userSuppliedValue: undefined,
};

View File

@@ -8,7 +8,7 @@ export default function NotFoundPage() {
<FormattedMessage
id="error.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="Error message when a page does not exist"
description="error message when a page does not exist"
/>
</p>
</div>

View File

@@ -1,43 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@edx/paragon';
export default function OneTimeDismissibleAlert(props) {
const [dismissed, setDismissed] = useState(localStorage.getItem(props.id) !== 'true');
const onClose = () => {
localStorage.setItem(props.id, 'true');
setDismissed(false);
};
return (
<Alert
variant={props.variant}
dismissible
icon={props.icon}
onClose={onClose}
show={dismissed}
>
<Alert.Heading>{props.header}</Alert.Heading>
<p>
{props.body}
</p>
</Alert>
);
}
OneTimeDismissibleAlert.propTypes = {
id: PropTypes.string.isRequired,
variant: PropTypes.string,
icon: PropTypes.func,
header: PropTypes.string,
body: PropTypes.string,
};
OneTimeDismissibleAlert.defaultProps = {
variant: 'success',
icon: undefined,
header: undefined,
body: undefined,
};

View File

@@ -50,8 +50,4 @@
line-height: 1.6rem;
}
}
.grayed-out{
opacity: 0.6; /* Real browsers */
filter: alpha(opacity = 60); /* MSIE */
}
}

View File

@@ -1,172 +0,0 @@
import React, { useState, useEffect } from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import {
ActionRow,
Form,
ModalDialog,
StatefulButton,
} from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
closeForm,
resetDrafts,
saveSettings,
updateDraft,
} from '../data/actions';
import { certPreferenceSelector } from '../data/selectors';
import commonMessages from '../AccountSettingsPage.messages';
import messages from './messages';
function CertificatePreference({
intl,
fieldName,
originalFullName,
originalVerifiedName,
saveState,
useVerifiedNameForCerts,
}) {
if (!originalVerifiedName) {
// If the user doesn't have an approved verified name, do not display this component
return null;
}
const dispatch = useDispatch();
const [checked, setChecked] = useState(false);
const [modalIsOpen, setModalIsOpen] = useState(false);
const formId = 'useVerifiedNameForCerts';
function handleCheckboxChange() {
if (!checked) {
if (fieldName === 'verified_name') {
dispatch(updateDraft(formId, true));
} else {
dispatch(updateDraft(formId, false));
}
} else {
setModalIsOpen(true);
}
}
function handleCancel() {
setModalIsOpen(false);
dispatch(resetDrafts());
}
function handleModalChange(e) {
if (e.target.value === 'fullName') {
dispatch(updateDraft(formId, false));
} else {
dispatch(updateDraft(formId, true));
}
}
function handleSubmit(e) {
e.preventDefault();
if (saveState === 'pending') {
return;
}
dispatch(saveSettings(formId, useVerifiedNameForCerts));
}
useEffect(() => {
if (fieldName === 'verified_name') {
setChecked(useVerifiedNameForCerts);
} else {
setChecked(!useVerifiedNameForCerts);
}
}, [useVerifiedNameForCerts]);
useEffect(() => {
if (modalIsOpen && saveState === 'complete') {
setModalIsOpen(false);
dispatch(closeForm(fieldName));
}
}, [modalIsOpen, saveState]);
return (
<>
<Form.Checkbox className="mt-1 mb-4" checked={checked} onChange={handleCheckboxChange}>
{intl.formatMessage(messages['account.settings.field.name.checkbox.certificate.select'])}
</Form.Checkbox>
<ModalDialog
title={intl.formatMessage(messages['account.settings.field.name.modal.certificate.title'])}
isOpen={modalIsOpen}
onClose={handleCancel}
size="lg"
hasCloseButton
isFullscreenOnMobile
>
<Form onSubmit={handleSubmit}>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages['account.settings.field.name.modal.certificate.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="overflow-hidden">
<Form.Group className="mb-4">
<Form.Label>
{intl.formatMessage(messages['account.settings.field.name.modal.certificate.select'])}
</Form.Label>
<Form.RadioSet
name={formId}
value={useVerifiedNameForCerts ? 'verifiedName' : 'fullName'}
onChange={handleModalChange}
>
<Form.Radio value="fullName">
{originalFullName}{' '}
({intl.formatMessage(messages['account.settings.field.name.modal.certificate.option.full'])})
</Form.Radio>
<Form.Radio value="verifiedName">
{originalVerifiedName}{' '}
({intl.formatMessage(messages['account.settings.field.name.modal.certificate.option.verified'])})
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="outline-primary" disabled={saveState === 'pending'}>
{intl.formatMessage(commonMessages['account.settings.editable.field.action.cancel'])}
</ModalDialog.CloseButton>
<StatefulButton
type="submit"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.field.name.modal.certificate.button.choose']),
}}
disabledStates={[]}
/>
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
</>
);
}
CertificatePreference.propTypes = {
intl: intlShape.isRequired,
fieldName: PropTypes.string.isRequired,
originalFullName: PropTypes.string,
originalVerifiedName: PropTypes.string,
saveState: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
};
CertificatePreference.defaultProps = {
originalFullName: '',
originalVerifiedName: '',
saveState: null,
useVerifiedNameForCerts: false,
};
export default connect(certPreferenceSelector)(injectIntl(CertificatePreference));

View File

@@ -1,22 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { handleRequestError } from '../../data/utils';
// eslint-disable-next-line import/prefer-default-export
export async function postVerifiedNameConfig(username, commitValues) {
const requestConfig = { headers: { Accept: 'application/json' } };
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name/config`;
const { useVerifiedNameForCerts } = commitValues;
const postValues = {
username,
use_verified_name_for_certs: useVerifiedNameForCerts,
};
const { data } = await getAuthenticatedHttpClient()
.post(requestUrl, postValues, requestConfig)
.catch(error => handleRequestError(error));
return data;
}

View File

@@ -1,36 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.field.name.checkbox.certificate.select': {
id: 'account.settings.field.name.certificate.select',
defaultMessage: 'If checked, this name will appear on your certificates and public-facing records.',
description: 'Label for checkbox describing that the selected name will appear on the users certificates.',
},
'account.settings.field.name.modal.certificate.title': {
id: 'account.settings.field.name.modal.certificate.title',
defaultMessage: 'Choose a preferred name for certificates and public-facing records',
description: 'Title instructing the user to choose a preferred name.',
},
'account.settings.field.name.modal.certificate.select': {
id: 'account.settings.field.name.modal.certificate.select',
defaultMessage: 'Select a name',
description: 'Label instructing the user to select a name.',
},
'account.settings.field.name.modal.certificate.option.full': {
id: 'account.settings.field.name.modal.certificate.option.full',
defaultMessage: 'Full Name',
description: 'Option representing the users full name.',
},
'account.settings.field.name.modal.certificate.option.verified': {
id: 'account.settings.field.name.modal.certificate.option.verified',
defaultMessage: 'Verified Name',
description: 'Option representing the users verified name.',
},
'account.settings.field.name.modal.certificate.button.choose': {
id: 'account.settings.field.name.modal.certificate.button.choose',
defaultMessage: 'Choose name',
description: 'Button to confirm the users name choice.',
},
});
export default messages;

View File

@@ -1,173 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import {
fireEvent,
render,
screen,
} from '@testing-library/react';
import { createMemoryHistory } from 'history';
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
import CertificatePreference from '../CertificatePreference'; // eslint-disable-line import/first
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
const history = createMemoryHistory();
const IntlCertificatePreference = injectIntl(CertificatePreference);
const mockStore = configureStore();
describe('NameChange', () => {
let props = {};
let store = {};
const formId = 'useVerifiedNameForCerts';
const updateDraft = 'UPDATE_DRAFT';
const labelText = 'If checked, this name will appear on your certificates and public-facing records.';
const reduxWrapper = children => (
<Router history={history}>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
</Router>
);
beforeEach(() => {
store = mockStore();
props = {
fieldName: 'name',
originalFullName: 'Ed X',
originalVerifiedName: 'edX Verified',
saveState: null,
useVerifiedNameForCerts: false,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
patch: async () => ({
data: { status: 200 },
catch: () => {},
}),
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3 }));
});
afterEach(() => jest.clearAllMocks());
it('does not render if there is no verified name', () => {
props = {
...props,
originalVerifiedName: '',
};
const wrapper = render(reduxWrapper(<IntlCertificatePreference {...props} />));
expect(wrapper).toMatchSnapshot();
});
it('does not trigger modal when checking empty checkbox, and updates draft immediately', () => {
props = {
...props,
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(false);
fireEvent.click(checkbox);
expect(screen.queryByRole('radiogroup')).toBeNull();
expect(mockDispatch).toHaveBeenCalledWith({
payload: { name: formId, value: false },
type: updateDraft,
});
});
it('triggers modal when attempting to uncheck checkbox', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);
fireEvent.click(checkbox);
expect(mockDispatch).not.toHaveBeenCalled();
screen.getByRole('radiogroup');
});
it('updates draft when changing radio value', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
const fullNameOption = screen.getByLabelText('Ed X (Full Name)');
const verifiedNameOption = screen.getByLabelText('edX Verified (Verified Name)');
expect(fullNameOption.checked).toEqual(true);
expect(verifiedNameOption.checked).toEqual(false);
fireEvent.click(verifiedNameOption);
expect(mockDispatch).toHaveBeenCalledWith({
payload: { name: formId, value: true },
type: updateDraft,
});
});
it('clears draft on cancel', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockDispatch).toHaveBeenCalledWith({ type: 'RESET_DRAFTS' });
expect(screen.queryByRole('radiogroup')).toBeNull();
});
it('submits', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
const submitButton = screen.getByText('Choose name');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith({
payload: { formId, commitValues: false },
type: 'ACCOUNT_SETTINGS__SAVE_SETTINGS',
});
});
it('checks box for verified name', () => {
props = {
...props,
fieldName: 'verified_name',
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);
});
});

View File

@@ -1,62 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NameChange does not render if there is no verified name 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View File

@@ -17,7 +17,7 @@ const ManagedProfileAlert = ({ profileDataManager }) => (
<FormattedMessage
id="account.settings.coaching.managed.alert"
defaultMessage="Your name is managed by {managerTitle}. Contact your administrator for help."
description="Alert message informing the user their account data is managed by a third party"
description="alert message informing the user their account data is managed by a third party"
values={{
managerTitle: <b>{profileDataManager}</b>,
}}

View File

@@ -114,7 +114,7 @@ exports[`CoachingConsent disables name field on enterprise user 1`] = `
className="mt-3"
>
<a
className="pgn__hyperlink default-link standalone-link mt-3 text-dark btn-link small"
className="mt-3 text-dark btn-link small"
href="http://localhost:18000/dashboard/"
onClick={[Function]}
target="_self"
@@ -263,7 +263,7 @@ exports[`CoachingConsent should render 1`] = `
className="mt-3"
>
<a
className="pgn__hyperlink default-link standalone-link mt-3 text-dark btn-link small"
className="mt-3 text-dark btn-link small"
href="http://localhost:18000/dashboard/"
onClick={[Function]}
target="_self"

View File

@@ -9,7 +9,6 @@ export const OPEN_FORM = 'OPEN_FORM';
export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
export const BEGIN_NAME_CHANGE = 'BEGIN_NAME_CHANGE';
// FETCH SETTINGS ACTIONS
@@ -26,7 +25,6 @@ export const fetchSettingsSuccess = ({
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
}) => ({
type: FETCH_SETTINGS.SUCCESS,
payload: {
@@ -34,7 +32,6 @@ export const fetchSettingsSuccess = ({
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
},
});
@@ -71,10 +68,6 @@ export const resetDrafts = () => ({
type: RESET_DRAFTS,
});
export const beginNameChange = (formId) => ({
type: BEGIN_NAME_CHANGE,
payload: { formId },
});
// SAVE SETTINGS ACTIONS
export const saveSettings = (formId, commitValues) => ({

View File

@@ -8,13 +8,11 @@ import {
UPDATE_DRAFT,
RESET_DRAFTS,
SAVE_MULTIPLE_SETTINGS,
BEGIN_NAME_CHANGE,
} from './actions';
import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from '../delete-account';
import { reducer as siteLanguageReducer, FETCH_SITE_LANGUAGES } from '../site-language';
import { reducer as resetPasswordReducer, RESET_PASSWORD } from '../reset-password';
import { reducer as nameChangeReducer, REQUEST_NAME_CHANGE } from '../name-change';
import { reducer as thirdPartyAuthReducer, DISCONNECT_AUTH } from '../third-party-auth';
export const defaultState = {
@@ -33,12 +31,7 @@ export const defaultState = {
deleteAccount: deleteAccountReducer(),
siteLanguage: siteLanguageReducer(),
resetPassword: resetPasswordReducer(),
nameChange: nameChangeReducer(),
thirdPartyAuth: thirdPartyAuthReducer(),
nameChangeModal: false,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {},
};
const reducer = (state = defaultState, action) => {
@@ -63,7 +56,6 @@ const reducer = (state = defaultState, action) => {
loading: false,
loaded: true,
loadingError: null,
verifiedNameHistory: action.payload.verifiedNameHistory,
};
case FETCH_SETTINGS.FAILURE:
return {
@@ -97,7 +89,6 @@ const reducer = (state = defaultState, action) => {
saveState: null,
errors: {},
drafts: {},
nameChangeModal: false,
};
}
return state;
@@ -115,15 +106,6 @@ const reducer = (state = defaultState, action) => {
drafts: {},
};
case BEGIN_NAME_CHANGE:
return {
...state,
saveState: 'error',
nameChangeModal: {
formId: action.payload.formId,
},
};
case SAVE_SETTINGS.BEGIN:
return {
...state,
@@ -137,6 +119,7 @@ const reducer = (state = defaultState, action) => {
values: { ...state.values, ...action.payload.values },
errors: {},
confirmationValues: {
...state.confirmationValues,
...action.payload.confirmationValues,
},
@@ -215,15 +198,6 @@ const reducer = (state = defaultState, action) => {
resetPassword: resetPasswordReducer(state.resetPassword, action),
};
case REQUEST_NAME_CHANGE.BEGIN:
case REQUEST_NAME_CHANGE.SUCCESS:
case REQUEST_NAME_CHANGE.FAILURE:
case REQUEST_NAME_CHANGE.RESET:
return {
...state,
nameChange: nameChangeReducer(state.nameChange, action),
};
case DISCONNECT_AUTH.BEGIN:
case DISCONNECT_AUTH.SUCCESS:
case DISCONNECT_AUTH.FAILURE:

View File

@@ -25,13 +25,11 @@ import {
saveMultipleSettingsBegin,
saveMultipleSettingsSuccess,
saveMultipleSettingsFailure,
beginNameChange,
} from './actions';
// Sub-modules
import { saga as deleteAccountSaga } from '../delete-account';
import { saga as resetPasswordSaga } from '../reset-password';
import { saga as nameChangeSaga } from '../name-change';
import {
saga as siteLanguageSaga,
patchPreferences,
@@ -40,12 +38,7 @@ import {
import { saga as thirdPartyAuthSaga } from '../third-party-auth';
// Services
import {
getSettings,
patchSettings,
getTimeZones,
getVerifiedNameHistory,
} from './service';
import { getSettings, patchSettings, getTimeZones } from './service';
export function* handleFetchSettings() {
try {
@@ -61,8 +54,6 @@ export function* handleFetchSettings() {
userId,
);
const verifiedNameHistory = yield call(getVerifiedNameHistory);
if (values.country) { yield put(fetchTimeZones(values.country)); }
yield put(fetchSettingsSuccess({
@@ -70,7 +61,6 @@ export function* handleFetchSettings() {
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));
@@ -108,9 +98,6 @@ export function* handleSaveSettings(action) {
yield put(closeForm(action.payload.formId));
} catch (e) {
if (e.fieldErrors) {
if (e.fieldErrors.name?.includes('verification')) {
yield put(beginNameChange('name'));
}
yield put(saveSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveSettingsFailure(e.message));
@@ -139,9 +126,6 @@ export function* handleSaveMultipleSettings(action) {
}
} catch (e) {
if (e.fieldErrors) {
if (e.fieldErrors.name?.includes('verification')) {
yield put(beginNameChange('name'));
}
yield put(saveMultipleSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveMultipleSettingsFailure(e.message));
@@ -164,7 +148,6 @@ export default function* saga() {
deleteAccountSaga(),
siteLanguageSaga(),
resetPasswordSaga(),
nameChangeSaga(),
thirdPartyAuthSaga(),
]);
}

View File

@@ -1,6 +1,5 @@
import { createSelector, createStructuredSelector } from 'reselect';
import { siteLanguageListSelector, siteLanguageOptionsSelector } from '../site-language';
import { compareVerifiedNamesByCreatedDate } from '../../utils';
export const storeName = 'accountSettings';
@@ -8,74 +7,9 @@ export const accountSettingsSelector = state => ({ ...state[storeName] });
const editableFieldNameSelector = (state, props) => props.name;
const verifiedNameSettingsSelector = createSelector(
accountSettingsSelector,
accountSettings => ({
history: accountSettings.verifiedNameHistory.results,
useVerifiedNameForCerts: accountSettings?.verifiedNameHistory.use_verified_name_for_certs,
}),
);
const sortedVerifiedNameHistorySelector = createSelector(
verifiedNameSettingsSelector,
verifiedNameSettings => {
const { history } = verifiedNameSettings;
if (Array.isArray(history)) {
return history.sort(compareVerifiedNamesByCreatedDate);
}
return [];
},
);
const mostRecentVerifiedNameSelector = createSelector(
sortedVerifiedNameHistorySelector,
sortedHistory => (sortedHistory.length > 0 ? sortedHistory[0] : null),
);
const mostRecentApprovedVerifiedNameValueSelector = createSelector(
sortedVerifiedNameHistorySelector,
mostRecentVerifiedNameSelector,
(sortedHistory, mostRecentVerifiedName) => {
const approvedVerifiedNames = sortedHistory.filter(name => name.status === 'approved');
const approvedVerifiedName = approvedVerifiedNames.length > 0 ? approvedVerifiedNames[0] : null;
let verifiedName = null;
switch (mostRecentVerifiedName && mostRecentVerifiedName.status) {
case 'approved':
case 'denied':
case 'pending':
verifiedName = approvedVerifiedName;
break;
case 'submitted':
verifiedName = mostRecentVerifiedName;
break;
default:
verifiedName = null;
}
return verifiedName;
},
);
const valuesSelector = createSelector(
accountSettingsSelector,
mostRecentApprovedVerifiedNameValueSelector,
(accountSettings, mostRecentApprovedVerifiedNameValue) => {
let useVerifiedNameForCerts = (
accountSettings.verifiedNameHistory?.use_verified_name_for_certs || false
);
if (Object.keys(accountSettings.confirmationValues).includes('useVerifiedNameForCerts')) {
useVerifiedNameForCerts = accountSettings.confirmationValues.useVerifiedNameForCerts;
}
return {
...accountSettings.values,
verified_name: mostRecentApprovedVerifiedNameValue?.verified_name,
useVerifiedNameForCerts,
};
},
accountSettings => accountSettings.values,
);
const draftsSelector = createSelector(
@@ -116,11 +50,6 @@ const errorSelector = createSelector(
accountSettings => accountSettings.errors,
);
const nameChangeModalSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.nameChangeModal,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
@@ -140,18 +69,7 @@ export const profileDataManagerSelector = createSelector(
export const staticFieldsSelector = createSelector(
accountSettingsSelector,
mostRecentVerifiedNameSelector,
(accountSettings, verifiedName) => {
const staticFields = [];
if (accountSettings.profileDataManager) {
staticFields.push('name', 'email', 'country');
}
if (verifiedName && ['submitted'].includes(verifiedName.status)) {
staticFields.push('verifiedName');
}
return staticFields;
},
accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []),
);
/**
@@ -167,11 +85,7 @@ const formValuesSelector = createSelector(
(values, drafts) => {
const formValues = {};
Object.entries(values).forEach(([name, value]) => {
if (typeof value === 'boolean') {
formValues[name] = chooseFormValue(drafts[name], value);
} else {
formValues[name] = chooseFormValue(drafts[name], value) || '';
}
formValues[name] = chooseFormValue(drafts[name], value) || '';
});
return formValues;
},
@@ -216,35 +130,21 @@ export const accountSettingsPageSelector = createSelector(
siteLanguageOptionsSelector,
siteLanguageSelector,
formValuesSelector,
valuesSelector,
draftsSelector,
errorSelector,
profileDataManagerSelector,
staticFieldsSelector,
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
nameChangeModalSelector,
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
(
accountSettings,
siteLanguageOptions,
siteLanguage,
formValues,
committedValues,
drafts,
formErrors,
profileDataManager,
staticFields,
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
) => ({
siteLanguageOptions,
siteLanguage,
@@ -255,37 +155,9 @@ export const accountSettingsPageSelector = createSelector(
countryTimeZoneOptions,
isActive: activeAccount,
formValues,
committedValues,
drafts,
formErrors,
profileDataManager,
staticFields,
tpaProviders: accountSettings.thirdPartyAuth.providers,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
}),
);
export const certPreferenceSelector = createSelector(
valuesSelector,
formValuesSelector,
mostRecentApprovedVerifiedNameValueSelector,
saveStateSelector,
errorSelector,
(
committedValues,
formValues,
mostRecentApprovedVerifiedNameValue,
saveState,
errors,
) => ({
originalFullName: committedValues?.name || '',
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
saveState,
formErrors: errors,
}),
);
@@ -332,12 +204,3 @@ export const demographicsSectionSelector = createSelector(
formErrors: errors,
}),
);
export const nameChangeSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
(accountSettings, formValues) => ({
...accountSettings.nameChange,
formValues,
}),
);

View File

@@ -7,7 +7,6 @@ import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
import { getDemographics, getDemographicsOptions, patchDemographics } from '../demographics/data/service';
import { DEMOGRAPHICS_FIELDS } from '../demographics/data/utils';
@@ -177,57 +176,12 @@ export async function shouldDisplayDemographicsQuestions() {
return false;
}
export async function getVerifiedName() {
let data;
const client = getAuthenticatedHttpClient();
try {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name`;
({ data } = await client.get(requestUrl));
} catch (error) {
return {};
}
return data;
}
export async function getVerifiedNameHistory() {
let data;
const client = getAuthenticatedHttpClient();
try {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name/history`;
({ data } = await client.get(requestUrl));
} catch (error) {
return {};
}
return data;
}
export async function postVerifiedName(data) {
const requestConfig = { headers: { Accept: 'application/json' } };
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name`;
await getAuthenticatedHttpClient()
.post(requestUrl, data, requestConfig)
.catch(error => handleRequestError(error));
}
/**
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, Coaching, ThirdPartyAuth, and Demographics
*/
export async function getSettings(username, userRoles, userId) {
const [
account,
preferences,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
coaching,
shouldDisplayDemographicsQuestionsResponse,
demographics,
demographicsOptions,
] = await Promise.all([
const results = await Promise.all([
getAccount(username),
getPreferences(username),
getThirdPartyAuthProviders(),
@@ -240,15 +194,15 @@ export async function getSettings(username, userRoles, userId) {
]);
return {
...account,
...preferences,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
coaching,
shouldDisplayDemographicsSection: shouldDisplayDemographicsQuestionsResponse,
...demographics,
demographicsOptions,
...results[0],
...results[1],
thirdPartyAuthProviders: results[2],
profileDataManager: results[3],
timeZones: results[4],
coaching: results[5],
shouldDisplayDemographicsSection: results[6],
...results[7], // demographics
demographicsOptions: results[8],
};
}
@@ -263,19 +217,11 @@ export async function patchSettings(username, commitValues, userId) {
const preferenceKeys = ['time_zone'];
const coachingKeys = ['coaching'];
const demographicsKeys = DEMOGRAPHICS_FIELDS;
const certificateKeys = ['useVerifiedNameForCerts'];
const isDemographicsKey = (value, key) => key.includes('demographics');
const accountCommitValues = omit(
commitValues,
preferenceKeys,
coachingKeys,
demographicsKeys,
certificateKeys,
);
const accountCommitValues = omit(commitValues, preferenceKeys, coachingKeys, demographicsKeys);
const preferenceCommitValues = pick(commitValues, preferenceKeys);
const coachingCommitValues = pick(commitValues, coachingKeys);
const demographicsCommitValues = pickBy(commitValues, isDemographicsKey);
const certCommitValues = pick(commitValues, certificateKeys);
const patchRequests = [];
if (!isEmpty(accountCommitValues)) {
@@ -290,9 +236,6 @@ export async function patchSettings(username, commitValues, userId) {
if (!isEmpty(demographicsCommitValues)) {
patchRequests.push(patchDemographics(userId, demographicsCommitValues));
}
if (!isEmpty(certCommitValues)) {
patchRequests.push(postVerifiedNameConfig(username, certCommitValues));
}
const results = await Promise.all(patchRequests);
// Assigns in order of requests. Preference keys

View File

@@ -66,10 +66,6 @@ export class DeleteAccount extends React.Component {
? 'account.settings.delete.account.text.2.edX'
: 'account.settings.delete.account.text.2';
const optInInstructionMessageId = getConfig().MARKETING_EMAILS_OPT_IN
? 'account.settings.delete.account.please.confirm'
: 'account.settings.delete.account.please.activate';
return (
<div>
<h2 className="section-heading">
@@ -112,8 +108,8 @@ export class DeleteAccount extends React.Component {
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId={optInInstructionMessageId}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email-"
instructionMessageId="account.settings.delete.account.please.activate"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
/>
)}

View File

@@ -16,6 +16,21 @@ Array [
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
@@ -192,6 +207,21 @@ Array [
}
tabIndex={0}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={1}
/>
<div
data-focus-lock-disabled={false}
onBlur={[Function]}
@@ -388,6 +418,21 @@ Array [
}
tabIndex={0}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={1}
/>
<div
data-focus-lock-disabled={false}
onBlur={[Function]}

View File

@@ -28,7 +28,6 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
@@ -77,7 +76,6 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
@@ -121,8 +119,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<span>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email-"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
onClick={[Function]}
target="_self"
>
@@ -163,7 +160,6 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
@@ -207,7 +203,6 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<span>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/207206067"
onClick={[Function]}
target="_self"

View File

@@ -16,6 +16,21 @@ Array [
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
@@ -113,6 +128,21 @@ Array [
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
@@ -210,6 +240,21 @@ Array [
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
@@ -307,6 +352,21 @@ Array [
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
@@ -419,6 +479,21 @@ Array [
}
tabIndex={0}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={1}
/>
<div
data-focus-lock-disabled={false}
onBlur={[Function]}

View File

@@ -51,11 +51,6 @@ const messages = defineMessages({
defaultMessage: 'activate your account',
description: 'This is the text on a link that goes to the support page. It is part of this sentence: Before proceeding, please activate your account.',
},
'account.settings.delete.account.please.confirm': {
id: 'account.settings.delete.account.please.confirm',
defaultMessage: 'confirm your account',
description: 'This is the text on a link that goes to the support page. It is part of this sentence: Before proceeding, please confirm your account.',
},
'account.settings.delete.account.please.unlink': {
id: 'account.settings.delete.account.please.unlink',
defaultMessage: 'unlink all social media accounts',

View File

@@ -12,47 +12,20 @@ exports[`DemographicsSection should render 1`] = `
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -112,7 +85,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -176,7 +148,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -247,7 +218,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -311,7 +281,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -375,7 +344,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -439,7 +407,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -503,7 +470,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -567,7 +533,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -631,7 +596,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -658,47 +622,20 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -772,7 +709,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -836,7 +772,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -907,7 +842,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -971,7 +905,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1035,7 +968,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1099,7 +1031,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1163,7 +1094,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1227,7 +1157,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1291,7 +1220,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1318,47 +1246,20 @@ exports[`DemographicsSection should render an Alert when demographicsOptions pro
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -1391,47 +1292,20 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -1491,7 +1365,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1555,7 +1428,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Hispanic, Latin, or Spanish origin, White
@@ -1619,7 +1491,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1683,7 +1554,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1747,7 +1617,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1811,7 +1680,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1875,7 +1743,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1939,7 +1806,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2003,7 +1869,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2030,47 +1895,20 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -2130,7 +1968,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2194,7 +2031,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Asian
@@ -2258,7 +2094,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2322,7 +2157,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2386,7 +2220,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2450,7 +2283,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2514,7 +2346,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2578,7 +2409,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2642,7 +2472,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2669,47 +2498,20 @@ exports[`DemographicsSection should set user input correctly when user provides
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -2769,7 +2571,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2833,7 +2634,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -2904,7 +2704,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2968,7 +2767,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3032,7 +2830,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3096,7 +2893,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3160,7 +2956,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Other: test
@@ -3224,7 +3019,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3288,7 +3082,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3315,47 +3108,20 @@ exports[`DemographicsSection should set user input correctly when user provides
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -3415,7 +3181,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer to self describe: test
@@ -3479,7 +3244,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -3550,7 +3314,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3614,7 +3377,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3678,7 +3440,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3742,7 +3503,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3806,7 +3566,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3870,7 +3629,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3934,7 +3692,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond

View File

@@ -1,203 +0,0 @@
import React, { useEffect, useState } from 'react';
import { connect, useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
Button,
Col,
Form,
ModalDialog,
StatefulButton,
} from '@edx/paragon';
import { closeForm, saveSettingsReset } from '../data/actions';
import { nameChangeSelector } from '../data/selectors';
import { requestNameChange, requestNameChangeFailure, requestNameChangeReset } from './data/actions';
import messages from './messages';
function NameChangeModal({
targetFormId,
errors,
formValues,
intl,
saveState,
}) {
const dispatch = useDispatch();
const { push } = useHistory();
const { username } = getAuthenticatedUser();
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
const [confirmedWarning, setConfirmedWarning] = useState(false);
function resetLocalState() {
setConfirmedWarning(false);
dispatch(requestNameChangeReset());
}
function handleChange(e) {
setVerifiedNameInput(e.target.value);
}
function handleClose() {
resetLocalState();
dispatch(closeForm(targetFormId));
dispatch(saveSettingsReset());
}
function handleSubmit(e) {
e.preventDefault();
if (saveState === 'pending') {
return;
}
if (!verifiedNameInput) {
dispatch(requestNameChangeFailure({
verified_name: intl.formatMessage(messages['account.settings.name.change.error.valid.name']),
}));
} else {
const draftProfileName = targetFormId === 'name' ? formValues.name : null;
dispatch(requestNameChange(username, draftProfileName, verifiedNameInput));
}
}
useEffect(() => {
if (saveState === 'complete') {
handleClose();
push(`/id-verification?next=${encodeURIComponent('account/settings')}`);
}
}, [saveState]);
function renderErrors() {
if (Object.keys(errors).length > 0) {
return (
<>
{Object.entries(errors).map(([key, value]) => (
<Form.Control.Feedback type="invalid" key={key}>
{
key === 'general_error'
? intl.formatMessage(messages['account.settings.name.change.error.general'])
: value
}
</Form.Control.Feedback>
))}
</>
);
}
return null;
}
function renderTitle() {
if (!confirmedWarning) {
return intl.formatMessage(messages['account.settings.name.change.title.id']);
}
return intl.formatMessage(messages['account.settings.name.change.title.begin']);
}
function renderBody() {
if (!confirmedWarning) {
return (
<Alert variant="warning">
<p>
{intl.formatMessage(messages['account.settings.name.change.warning.one'])}
</p>
<p>
{intl.formatMessage(messages['account.settings.name.change.warning.two'])}
</p>
</Alert>
);
}
return (
<Form.Group as={Col} isInvalid={Object.keys(errors).length > 0}>
<Form.Label>
{intl.formatMessage(messages['account.settings.name.change.id.name.label'])}
</Form.Label>
<Form.Control
type="text"
name="verifiedName"
placeholder={intl.formatMessage(messages['account.settings.name.change.id.name.placeholder'])}
value={verifiedNameInput}
onChange={handleChange}
/>
{renderErrors()}
</Form.Group>
);
}
function renderContinueButton() {
if (!confirmedWarning) {
return (
<Button variant="primary" onClick={() => setConfirmedWarning(true)}>
{intl.formatMessage(messages['account.settings.name.change.continue'])}
</Button>
);
}
return (
<StatefulButton
type="submit"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.name.change.continue']),
}}
disabledStates={[]}
/>
);
}
return (
<ModalDialog
title={renderTitle()}
isOpen
hasCloseButton={false}
onClose={handleClose}
>
<Form onSubmit={handleSubmit}>
<ModalDialog.Header>
<ModalDialog.Title>
{renderTitle()}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="mb-3 overflow-hidden">
{renderBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages['account.settings.name.change.cancel'])}
</ModalDialog.CloseButton>
{renderContinueButton()}
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
);
}
NameChangeModal.propTypes = {
targetFormId: PropTypes.string.isRequired,
errors: PropTypes.shape({}).isRequired,
formValues: PropTypes.shape({
name: PropTypes.string,
verified_name: PropTypes.string,
}).isRequired,
saveState: PropTypes.string,
intl: intlShape.isRequired,
};
NameChangeModal.defaultProps = {
saveState: null,
};
export default connect(nameChangeSelector)(injectIntl(NameChangeModal));

View File

@@ -1,25 +0,0 @@
import { AsyncActionType } from '../../data/utils';
export const REQUEST_NAME_CHANGE = new AsyncActionType('ACCOUNT_SETTINGS', 'REQUEST_NAME_CHANGE');
export const requestNameChange = (username, profileName, verifiedName) => ({
type: REQUEST_NAME_CHANGE.BASE,
payload: { username, profileName, verifiedName },
});
export const requestNameChangeBegin = () => ({
type: REQUEST_NAME_CHANGE.BEGIN,
});
export const requestNameChangeSuccess = () => ({
type: REQUEST_NAME_CHANGE.SUCCESS,
});
export const requestNameChangeFailure = errors => ({
type: REQUEST_NAME_CHANGE.FAILURE,
payload: { errors },
});
export const requestNameChangeReset = () => ({
type: REQUEST_NAME_CHANGE.RESET,
});

View File

@@ -1,44 +0,0 @@
import { REQUEST_NAME_CHANGE } from './actions';
export const defaultState = {
saveState: null,
errors: {},
};
const reducer = (state = defaultState, action = null) => {
if (action !== null) {
switch (action.type) {
case REQUEST_NAME_CHANGE.BEGIN:
return {
...state,
saveState: 'pending',
errors: {},
};
case REQUEST_NAME_CHANGE.SUCCESS:
return {
...state,
saveState: 'complete',
};
case REQUEST_NAME_CHANGE.FAILURE:
return {
...state,
saveState: 'error',
errors: action.payload.errors || { general_error: 'A technical error occurred. Please try again.' },
};
case REQUEST_NAME_CHANGE.RESET:
return {
...state,
saveState: null,
errors: {},
};
default:
}
}
return state;
};
export default reducer;

View File

@@ -1,40 +0,0 @@
import { put, call, takeEvery } from 'redux-saga/effects';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { postVerifiedName } from '../../data/service';
import {
REQUEST_NAME_CHANGE,
requestNameChangeBegin,
requestNameChangeSuccess,
requestNameChangeFailure,
} from './actions';
import { postNameChange } from './service';
export function* handleRequestNameChange(action) {
let { name: profileName } = getAuthenticatedUser();
try {
yield put(requestNameChangeBegin());
if (action.payload.profileName) {
yield call(postNameChange, action.payload.profileName);
profileName = action.payload.profileName;
}
yield call(postVerifiedName, {
username: action.payload.username,
verified_name: action.payload.verifiedName,
profile_name: profileName,
});
yield put(requestNameChangeSuccess());
} catch (err) {
if (err.customAttributes?.httpErrorResponseData) {
yield put(requestNameChangeFailure(JSON.parse(err.customAttributes.httpErrorResponseData)));
} else {
yield put(requestNameChangeFailure());
}
}
}
export default function* saga() {
yield takeEvery(REQUEST_NAME_CHANGE.BASE, handleRequestNameChange);
}

View File

@@ -1,17 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { handleRequestError } from '../../data/utils';
// eslint-disable-next-line import/prefer-default-export
export async function postNameChange(name) {
// Requests a pending name change, rather than saving the account name immediately
const requestConfig = { headers: { Accept: 'application/json' } };
const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/name_change/`;
const { data } = await getAuthenticatedHttpClient()
.post(requestUrl, { name }, requestConfig)
.catch(error => handleRequestError(error));
return data;
}

View File

@@ -1,4 +0,0 @@
export { default } from './NameChange';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { REQUEST_NAME_CHANGE } from './data/actions';

View File

@@ -1,56 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.name.change.title.id': {
id: 'account.settings.name.change.title.id',
defaultMessage: 'This name change requires identity verification',
description: 'Inform the user that changing their name requires identity verification',
},
'account.settings.name.change.title.begin': {
id: 'account.settings.name.change.title.begin',
defaultMessage: 'Before we begin',
description: 'Title before beginning the ID verification process',
},
'account.settings.name.change.warning.one': {
id: 'account.settings.name.change.warning.one',
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.',
description: 'Warning informing the user that a name change will update the name on all of their certificates.',
},
'account.settings.name.change.warning.two': {
id: 'account.settings.name.change.warning.two',
defaultMessage: 'This action cannot be undone without verifying your identity.',
description: 'Warning informing the user that a name change cannot be undone without ID verification.',
},
'account.settings.name.change.id.name.label': {
id: 'account.settings.name.change.id.name.label',
defaultMessage: 'Enter your name as it appears on your unexpired student, work, or government-issued identification card.',
description: 'Form label instructing the user to enter the name on their ID.',
},
'account.settings.name.change.id.name.placeholder': {
id: 'account.settings.name.change.id.name.placeholder',
defaultMessage: 'Enter the name on your photo ID',
description: 'Form label instructing the user to enter the name on their ID.',
},
'account.settings.name.change.error.valid.name': {
id: 'account.settings.name.change.error.valid.name',
defaultMessage: 'Please enter a valid name.',
description: 'Error that appears when the user doesnt enter a valid name.',
},
'account.settings.name.change.error.general': {
id: 'account.settings.name.change.error.general',
defaultMessage: 'A technical error occurred. Please try again.',
description: 'Generic error message.',
},
'account.settings.name.change.continue': {
id: 'account.settings.name.change.continue',
defaultMessage: 'Continue',
description: 'Continue button.',
},
'account.settings.name.change.cancel': {
id: 'account.settings.name.change.cancel',
defaultMessage: 'Cancel',
description: 'Cancel button.',
},
});
export default messages;

View File

@@ -1,172 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import {
fireEvent,
render,
screen,
} from '@testing-library/react';
import { createMemoryHistory } from 'history';
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
import NameChange from '../NameChange'; // eslint-disable-line import/first
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
const history = createMemoryHistory();
const IntlNameChange = injectIntl(NameChange);
const mockStore = configureStore();
describe('NameChange', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<Router history={history}>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
</Router>
);
beforeEach(() => {
store = mockStore();
props = {
targetFormId: 'test_form',
errors: {},
formValues: {
name: 'edx edx',
verified_name: 'edX Verified',
},
saveState: null,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
patch: async () => ({
data: { status: 200 },
catch: () => {},
}),
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, username: 'edx' }));
});
afterEach(() => jest.clearAllMocks());
it('renders populated input after clicking continue if verified_name in form data', async () => {
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
render(reduxWrapper(<IntlNameChange {...props} />));
expect(getInput()).toBeNull();
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
expect(getInput().value).toBe('edX Verified');
});
it('renders empty input after clicking continue if verified_name not in form data', async () => {
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
const formProps = {
...props,
formValues: {
name: 'edx edx',
},
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
expect(getInput().value).toBe('');
});
it('dispatches verifiedName on submit if targetForm is not "name"', async () => {
const dispatchData = {
payload: {
profileName: null,
username: 'edx',
verifiedName: 'Verified Name',
},
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
};
render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
});
it('dispatches both profileName and verifiedName on submit if the targetForm is "name"', async () => {
const dispatchData = {
payload: {
profileName: 'edx edx',
username: 'edx',
verifiedName: 'Verified Name',
},
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
};
const formProps = {
...props,
targetFormId: 'name',
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
});
it('does not dispatch action while pending', async () => {
props.saveState = 'pending';
render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
fireEvent.click(submitButton);
expect(mockDispatch).not.toHaveBeenCalled();
});
it('routes to IDV when name change request is successful', async () => {
props.saveState = 'complete';
render(reduxWrapper(<IntlNameChange {...props} />));
expect(history.location.pathname).toEqual('/id-verification');
});
});

BIN
src/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,4 +0,0 @@
export const IDLE_STATUS = 'idle';
export const LOADING_STATUS = 'loading';
export const SUCCESS_STATUS = 'success';
export const FAILURE_STATUS = 'failure';

View File

@@ -1,55 +0,0 @@
import { useEffect, useState } from 'react';
import {
IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS, FAILURE_STATUS,
} from './constants';
// eslint-disable-next-line import/prefer-default-export
export function useAsyncCall(asyncFunc) {
// React doesn't batch setStates call in async useEffect hooks,
// so we use a combined object here to ensure that users
// re-render once.
const [data, setData] = useState({ status: IDLE_STATUS });
useEffect(
() => {
(async () => {
setData(currData => ({ ...currData, status: LOADING_STATUS }));
const response = await asyncFunc();
if (Object.keys(response).length === 0) {
setData(currData => ({ ...currData, status: FAILURE_STATUS, data: response }));
} else {
setData(currData => ({ ...currData, status: SUCCESS_STATUS, data: response }));
}
})();
},
[asyncFunc],
);
return data;
}
// Redirect the user to their original location based on session storage
export function useRedirect() {
const [redirect, setRedirect] = useState({
location: 'dashboard',
text: 'id.verification.return.dashboard',
});
useEffect(() => {
if (sessionStorage.getItem('courseId')) {
setRedirect({
location: `courses/${sessionStorage.getItem('courseId')}`,
text: 'id.verification.return.course',
});
} else if (sessionStorage.getItem('next')) {
setRedirect({
location: sessionStorage.getItem('next'),
text: 'id.verification.return.generic',
});
}
}, []);
return redirect;
}

View File

@@ -1,5 +1,5 @@
{
"account.settings.message.duplicate.tpa.provider": "The {provider} account you selected is already linked to another {siteName} account.",
"account.settings.message.duplicate.tpa.provider": "إنّ حساب {provider} الذي اخترتَه مرتبط مسبقًا بحساب آخر في edX. ",
"account.settings.message.managed.settings": "تتم إدارة إعدادات الملف الشخصي بواسطة {ManagerTitle}. اتصل بالمسؤول أو {support} للحصول على المساعدة.",
"account.settings.message.managed.settings.support": "الدعم",
"account.settings.page.heading": "إعدادات الحساب",
@@ -14,40 +14,16 @@
"account.settings.section.demographics.information": "معلومات اختيارية",
"account.settings.section.site.preferences": "تفضيلات الموقع",
"account.settings.section.linked.accounts": "الحسابات المرتبطة",
"account.settings.section.linked.accounts.description": "You can link your identity accounts to simplify signing in to {siteName}.",
"account.settings.section.linked.accounts.description": "يمكنك ربط حساباتك الشخصية لتسهيل عملية تسجيل دخولك إلى edX.",
"account.settings.field.username": "اسم المستخدم",
"account.settings.field.username.help.text": "The name that identifies you on {siteName}. You cannot change your username.",
"account.settings.field.username.help.text": "اسم المستخدم الخاص بك الذي يميزك في edX. لا يمكنك تغيير اسم المستخدم الخاص بك لاحقاً.",
"account.settings.field.full.name": "الاسم الكامل",
"account.settings.field.full.name.empty": "إضافة اسم",
"account.settings.field.full.name.help.text": "الاسم المستخدم للتحقق من هويتك والذي سوف يظهر على الشهادات الخاصة بك.",
"account.settings.field.full.name.help.text.default": "The name that appears on your public profile.",
"account.settings.field.full.name.help.text.default.certificate": "This name is selected to appear on your certificates and public-facing records.",
"account.settings.field.name.verified": "Verified name",
"account.settings.field.name.verified.help.text.verified": "This name has been verified by photo ID.",
"account.settings.field.name.verified.help.text.verified.proctored": "This name has been verified by proctoring.",
"account.settings.field.name.verified.help.text.verified.certificate": "This name has been verified by photo ID, and is selected to appear on your certificates and public-facing records.",
"account.settings.field.name.verified.help.text.verified.proctored.certificate": "This name has been verified by proctoring, and is selected to appear on your certificates and public-facing records.",
"account.settings.field.name.verified.help.text.submitted": "Verification has been submitted. This usually takes 48 hours or less. Verified name cannot be changed at this time.",
"account.settings.field.name.verified.help.text.submitted.proctored": "Your proctored exam has been submitted. Verified name cannot be changed at this time. Please check back in 2-5 days.",
"account.settings.field.name.verified.help.text.submitted.certificate": "When identity verification is successful, this name will appear on your certificates and public-facing records. Verified name cannot be changed at this time.",
"account.settings.field.name.verified.help.text.submitted.proctored.certificate": "Once your proctored exam passes review, this name will appear on your certificate and public-facing records. Verified Name cannot be changed at this time.",
"account.settings.field.name.verified.verification.help": "Enter your name as it appears on your unexpired student, work, or government-issued identification card.",
"account.settings.field.full.name.help.text.submitted": "Verification has been submitted. This usually takes 48 hours or less. Full name cannot be changed at this time.",
"account.settings.field.full.name.help.text.submitted.proctored": "Your proctored exam has been submitted. Full name cannot be changed at this time. Please check back in 2-5 days.",
"account.settings.field.full.name.help.text.submitted.certificate": "When identity verification is successful, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.",
"account.settings.field.full.name.help.text.submitted.proctored.certificate": "Once your proctored exam passes review, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.",
"account.settings.field.name.verified.success.message": "Your identity verification request has successfully completed. You now have the option of selecting which name you prefer to appear on your certificates and public-records.",
"account.settings.field.name.verified.success.message.header": "Your name change request is complete!",
"account.settings.field.name.verified.failure.message": "Your most recent identity verification attempt did not pass. Related account settings have been restored.",
"account.settings.field.name.verified.failure.message.header": "We were not able to verify your identity.",
"account.settings.field.name.verified.failure.message.help.link": "Learn more about ID verification",
"account.settings.field.name.verified.submitted.message": "Your identity verification request has been submitted and usually takes between 24 and 48 hours to complete.",
"account.settings.field.name.verified.submitted.message.certificate": "When your request is approved, your updated name will appear on all associated certificates and public-facing records.",
"account.settings.field.name.verified.submitted.message.header": "Your name change request is almost complete!",
"account.settings.field.email": "البريد الالكتروني (الدخول)",
"account.settings.field.email.empty": "إضافة عنوان البريد الإلكتروني",
"account.settings.field.email.confirmation": "لقد أرسلنا رسالة تأكيد إلى {value}. انقر فوق الرابط في الرسالة لتحديث عنوان بريدك الإلكتروني.",
"account.settings.field.email.help.text": "You receive messages from {siteName} and course teams at this address.",
"account.settings.field.email.help.text": "أنت تتلقى رسائل من edX وفرق المساق على هذا العنوان.",
"account.settings.field.secondary.email": "عنوان البريد الإلكتروني للاسترداد",
"account.settings.field.secondary.email.empty": "إضافة عنوان البريد الإلكتروني للاسترداد",
"account.settings.field.secondary.email.confirmation": "لقد أرسلنا رسالة تأكيد إلى {value}. انقر فوق الرابط في الرسالة لتحديث عنوان بريد الاسترداد الإلكتروني.",
@@ -91,7 +67,7 @@
"account.settings.field.time.zone.all": "جميع المناطق الزمنية",
"account.settings.field.time.zone.country": "المنطقة الزمنية للدولة",
"account.settings.section.social.media": "روابط منصات التواصل الإجتماعي",
"account.settings.section.social.media.description": "Optionally, link your personal accounts to the social media icons on your {siteName} profile.",
"account.settings.section.social.media.description": "اختياريا، قم بربط حساباتك الشخصية بأيقونات منصات التواصل الاجتماعي في ملف التعريف الخاص بك.",
"account.settings.field.social.platform.name.linkedin": "لينكد إن",
"account.settings.field.social.platform.name.linkedin.empty": "إضافة عنوان ملف لينكد إن ",
"account.settings.jump.nav.delete.account": "احذف حسابي",
@@ -104,12 +80,6 @@
"account.settings.editable.field.action.edit": "تحرير",
"account.settings.static.field.empty": "لم يتم تحديد قيمة، فضلًا اتصل بمدير {enterprise} لتعيين بعض التغييرات.",
"account.settings.static.field.empty.no.admin": "لم يتم تحديد قيمة",
"account.settings.field.name.certificate.select": "If checked, this name will appear on your certificates and public-facing records.",
"account.settings.field.name.modal.certificate.title": "Choose a preferred name for certificates and public-facing records",
"account.settings.field.name.modal.certificate.select": "Select a name",
"account.settings.field.name.modal.certificate.option.full": "Full Name",
"account.settings.field.name.modal.certificate.option.verified": "Verified Name",
"account.settings.field.name.modal.certificate.button.choose": "Choose name",
"account.settings.coaching.consent.welcome.header": "لنبدأ",
"account.settings.coaching.consent.welcome.subheader": "نحن هنا لأجلك من البداية حتى النهاية",
"account.settings.coaching.consent.description": "تتضمن برامج البكالوريوس التدريب الذي يركز على مهنتك وتعليمك وكيفية تحقيق نتائج مبهرة من خلال التواصل الشخصي مع خبراء متمرسين. إذا كنت مهتمًا، فقدّم المعلومات أدناه وانقر فوق \"إرسال\"، وسيتصل بك شريكنا في التدريب عبر البريد الإلكتروني و/أو الرسائل النصية لمساعدتك على المضي قدمًا. تنطبق الشروط والأحكام.*",
@@ -131,20 +101,17 @@
"account.settings.delete.account.before.proceeding": "قبل المتابعة، يرجى {actionLink}.",
"account.settings.delete.account.header": "احذف حسابي",
"account.settings.delete.account.subheader": "نأسف لذهابك!",
"account.settings.delete.account.text.1": "Please note: Deletion of your account and personal data is permanent and cannot be undone. {siteName} will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.text.2": "Once your account is deleted, you cannot use it to take courses on {siteName}.",
"account.settings.delete.account.text.2.edX": "Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.text.3.link": "Follow these instructions for printing or downloading a certificate",
"account.settings.delete.account.text.warning": "Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on {siteName}.",
"account.settings.delete.account.text.1": "يرجى ملاحظة: إن حذف حسابك والبيانات الشخصية قرار نهائي ولا يمكن التراجع عنه. لن تتمكن edX من استعادة حسابك أو البيانات التي تم حذفها.",
"account.settings.delete.account.text.2": "بمجرد حذف حسابك ، لا يمكنك استخدامه لأخذ دورات تدريبية على تطبيق edX أو edx.org أو أي موقع آخر تستضيفه edX. وهذا يشمل الوصول إلى edx.org من نظام صاحب العمل أو الجامعة والوصول إلى المواقع الخاصة التي تقدمها MIT Open Learning و Wharton Executive Education و Harvard Medical School.",
"account.settings.delete.account.text.3.link": "اتبع الإرشادات التالية لطباعة أو تحميل شهادة.",
"account.settings.delete.account.text.warning": "تحذير: حذف الحساب قرار نهائي. يرجى قراءة ما سبق بعناية قبل المتابعة. هذا إجراء لا رجعة فيه، ولن تتمكن بعد الآن من استخدام البريد الإلكتروني نفسه على edX.",
"account.settings.delete.account.text.change.instead": "هل تريد تغيير البريد الإلكتروني أو الاسم أو كلمة المرور بدلاً من ذلك؟",
"account.settings.delete.account.button": "احذف حسابي",
"account.settings.delete.account.please.activate": "تنشيط حسابك",
"account.settings.delete.account.please.confirm": "confirm your account",
"account.settings.delete.account.please.unlink": "إلغاء ربط جميع حسابات التواصل الاجتماعي",
"account.settings.delete.account.modal.header": "هل أنت متأكد؟",
"account.settings.delete.account.modal.text.1": "You have selected \"Delete My Account\". Deletion of your account and personal data is permanent and cannot be undone. {siteName} will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.modal.text.2": "If you proceed, you will be unable to use this account to take courses on {siteName}.",
"account.settings.delete.account.modal.text.2.edX": "If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.modal.text.1": "لقد حددت \"حذف حسابي\". إن حذف حسابك وبياناتك الشخصية قرار نهائي لا يمكن التراجع عنه. لن تتمكن edX من استعادة حسابك أو البيانات التي تم حذفها.",
"account.settings.delete.account.modal.text.2": "عند المتابعة، فلن تتمكن من استخدام هذا الحساب للحصول على مساقات على تطبيق edX أو edx.org أو أي موقع آخر يستضيفه edX. وهذا يشمل الوصول إلى edx.org من نظام صاحب العمل أو الجامعة والوصول إلى المواقع الخاصة التي يقدمها معهد ماساتشوستس للتكنولوجيا التعليم المفتوح، وارتون التعليم التنفيذي، وكلية هارفارد الطبية.",
"account.settings.delete.account.modal.enter.password": "إذا كنت لا تزال ترغب في المتابعة وحذف حسابك ، فيرجى إدخال كلمة مرور حسابك:",
"account.settings.delete.account.modal.confirm.delete": "تعم، أحذف",
"account.settings.delete.account.modal.confirm.cancel": "إلغاء",
@@ -155,8 +122,7 @@
"account.settings.delete.account.modal.after.header": "نأسف لذهابك! سيتم حذف حسابك قريبا.",
"account.settings.delete.account.modal.after.text": "قد يستغرق حذف الحساب ، بما في ذلك الإزالة من قوائم البريد الإلكتروني ، بضعة أسابيع حتى تتم معالجته بالكامل من خلال نظامنا. إذا كنت ترغب في إلغاء الاشتراك في رسائل البريد الإلكتروني قبل ذلك الحين ، يرجى إلغاء الاشتراك من تذييل أي بريد إلكتروني.",
"account.settings.delete.account.modal.after.button": "إغلاق ",
"account.settings.delete.account.text.3.edX": "You may also lose access to verified certificates and other program credentials like MicroMasters certificates. You can make a copy of these for your records before proceeding with deletion. {actionLink}.",
"account.settings.delete.account.text.3": "You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.",
"account.settings.delete.account.text.3": "قد تفقد أيضًا إمكانية الوصول إلى الشهادات التي تم التحقق منها وبيانات اعتماد البرامج الأخرى مثل شهادات MicroMasters. إذا أردت عمل نسخة من هذه السجلات قبل متابعة الحذف، {actionLink}.",
"account.settings.message.demographics.service.issue": "حدث خطأ أثناء محاولة استرداد معلومات حسابك أو حفظها. يرجى المحاولة مرة أخرى لاحقًا.",
"account.settings.field.demographics.gender": "هوية الجنس",
"account.settings.field.demographics.gender.empty": "إضافة هوية الجنس",
@@ -187,17 +153,7 @@
"account.settings.field.demographics.future_work_sector": "مجال العمل المستقبلي",
"account.settings.field.demographics.future_work_sector.empty": "إضافة مجال العمل",
"account.settings.field.demographics.work_sector.options.empty": "حدد مجال العمل",
"account.settings.section.demographics.why": "Why does {siteName} collect this information?",
"account.settings.name.change.title.id": "This name change requires identity verification",
"account.settings.name.change.title.begin": "Before we begin",
"account.settings.name.change.warning.one": "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.",
"account.settings.name.change.warning.two": "This action cannot be undone without verifying your identity.",
"account.settings.name.change.id.name.label": "Enter your name as it appears on your unexpired student, work, or government-issued identification card.",
"account.settings.name.change.id.name.placeholder": "Enter the name on your photo ID",
"account.settings.name.change.error.valid.name": "Please enter a valid name.",
"account.settings.name.change.error.general": "A technical error occurred. Please try again.",
"account.settings.name.change.continue": "Continue",
"account.settings.name.change.cancel": "Cancel",
"account.settings.section.demographics.why": "لماذا تجمع edX هذه المعلومات؟",
"error.notfound.message": "الصفحة التي تبحث عنها غير متوفرة أو هناك خطأ في نص الرابط. الرجاء التحقق من الرابط والمحاولة مجددا.",
"account.settings.editable.field.password.reset.button.confirmation.support.link": "الدعم الفني",
"account.settings.editable.field.password.reset.button.confirmation": "لقد أرسلنا رسالة إلى {email}. انقر فوق الرابط في الرسالة لإعادة تعيين كلمة المرور. إذا لم يتم استلام الرسالة؟ اتصل بـ {technicalSupportLink}.",
@@ -209,21 +165,21 @@
"account.settings.sso.account.disconnect.error": "حدثت مشكلة أثناء قطع اتصال هذا الحساب، اتصل بالدعم عند استمرار المشكلة.",
"account.settings.sso.unlink.account": "إلغاء ربط حساب {name} ",
"account.settings.sso.no.providers": "لا يمكن ربط أية حسابات حاليًا",
"id.verification.access.blocked.denied": "We cannot verify your identity at this time. If you have yet to activate your account, please check your spam folder for the activation email from {email}.",
"id.verification.access.blocked.denied": "لا يمكنك التحقق من هويتك في الوقت الحالي. إذا لم تقم بعد بتنشيط حسابك، فيرجى التحقق من مجلد البريد المهمل للحصول على رسالة التفعيل من {email}.",
"id.verification.next": "التالي",
"id.verification.support": "support",
"id.verification.example.card.alt": "مثال على بطاقة هوية صحيحة بالاسم الكامل وصورة.",
"id.verification.requirements.title": "متطلبات التحقق من الصورة",
"id.verification.requirements.description": "In order to complete Photo Verification, you will need the following:",
"id.verification.requirements.description": "يجب عليك اتباع الآتي لإكمال عملية التحقق الإلكتروني من هويتك:",
"id.verification.requirements.card.device.title": "جهاز مزود بكاميرا",
"id.verification.requirements.card.device.allow": "موافق",
"id.verification.requirements.card.id.title": "Photo Identification Card",
"id.verification.requirements.card.id.text": "You need a valid identification card that contains your full name and photo, such as a drivers license or passport.",
"id.verification.requirements.card.id.title": "صورة التحقق من الشخصية.",
"id.verification.requirements.card.id.text": "تحتاج إلى بطاقة هوية صحيحة للتحقق تحوي اسمك الكامل وصورتك.",
"id.verification.privacy.title": "بيانات الخصوصية.",
"id.verification.privacy.need.photo.question": "Why does {siteName} need my photo?",
"id.verification.privacy.need.photo.question": "لماذا تحتاج edX إلى صورتي؟ ",
"id.verification.privacy.need.photo.answer": "نستخدم صور التحقق الخاصة بك لتأكيد هويتك والتأكد من صحة شهادتك.",
"id.verification.privacy.do.with.photo.question": "What does {siteName} do with this photo?",
"id.verification.privacy.do.with.photo.answer": "We securely encrypt your photo and send it our authorization service for review. Your photo and information are not saved or visible anywhere on {siteName} after the verification process is complete.",
"id.verification.privacy.do.with.photo.question": "ما الذي تفعله edX بهذه الصورة؟",
"id.verification.privacy.do.with.photo.answer": "سنقوم بتشفير صورتك بأمان وإرسالها لخدمة التحقق للمراجعة . لن يتم حفظ صورتك ومعلوماتك أو عرضها في أي مكان على edX بعد اكتمال عملية التحقق.",
"id.verification.access.blocked.title": "التحقق من الهوية",
"id.verification.access.blocked.enrollment": "أنت الآن ملتحق بمساق يتطلب التحقق من الهوية.",
"id.verification.access.blocked.pending": "لقد قمت بالفعل بإرسال معلومات التحقق الخاصة بك. ستصلك رسالة على لوحة المعلومات عند اكتمال عملية التحقق (عادةً خلال 5 أيام).",
@@ -289,58 +245,70 @@
"id.verification.photo.tips.list.well.lit": "أنّ الإضاءة جيّدة على وجهك.",
"id.verification.photo.tips.list.inside.frame": "أنّ وجهك داخل إطار الصورة بالكامل.",
"id.verification.portrait.photo.title.camera": "التقط صورة لنفسك",
"id.verification.portrait.photo.title.upload": "ارفع صورتك",
"id.verification.portrait.photo.preview.alt": "معاينة صورة وجه المستخدم.",
"id.verification.portrait.photo.instructions.camera": "عندما يكون وجهك في موضعه، استخدم زر التقاط صورة أدناه لالتقاط الصورة.",
"id.verification.portrait.photo.instructions.upload": "Please upload a portrait photo. Ensure your entire face fits inside the frame and is well-lit. Supported formats: ",
"id.verification.camera.help.sight.question": "ماذا لو لم أتمكن من رؤية صورة الكاميرا ؟ أو إذا لم أتمكن من رؤية صورتي لتحديد أي جانب مرئي؟",
"id.verification.camera.help.sight.answer.portrait": "قد تتمكن من إكمال إجراء التقاط الصور من دون مساعدة، ولكن قد يتطلب الأمر بضع محاولات ضبط وضع الكاميرا بشكل صحيح. يختلف وضع الكاميرا المثالي باختلاف الكمبيوتر، ولكن بشكل عام، يكون أفضل موضع للتصوير في الرأس هو 12 إلى 18 بوصة (30-45 سم) تقريبًا من الكاميرا، مع وضع الرأس في المنتصف بالنسبة إلى شاشة الكمبيوتر. إذا تم رفض الصور التي ترسلها، فحاول تحريك اتجاه الكمبيوتر أو الكاميرا لتغيير زاوية الإضاءة.",
"id.verification.camera.help.sight.answer.id": "قد تتمكن من إكمال إجراء التقاط الصور من دون مساعدة، ولكن قد يتطلب الأمر بضع محاولات لضبط وضع الكاميرا بشكل صحيح. يختلف الوضع الأمثل للكاميرا باختلاف جهاز الكمبيوتر، ولكن بشكل عام، يكون أفضل وضع لصورة بطاقة تعريف من 8 إلى 12 بوصة (من 20 إلى 30 سم) عن الكاميرا، مع وضع بطاقة الهوية في الوسط بالنسبة للكاميرا. إذا تم رفض الصور التي ترسلها، فحاول تحريك اتجاه الكمبيوتر أو الكاميرا لتغيير زاوية الإضاءة. إن السبب الأكثر شيوعاً للرفض هو عدم القدرة على قراءة النص الموجود على بطاقة الهوية.",
"id.verification.camera.help.difficulty.question.portrait": "ماذا لو واجهت صعوبة في تثبيت رأسي في الموضع المناسب للكاميرا؟",
"id.verification.camera.help.difficulty.question.id": "ماذا لو واجهت صعوبة في تثبيت بطاقة هويتي في الموضع المناسب للكاميرا؟",
"id.verification.camera.help.difficulty.answer": "If you require assistance with taking a photo for submission, contact {siteName} support for additional suggestions.",
"id.verification.id.photo.unclear.question": "Is your ID card image not clear or too blurry?",
"id.verification.id.tips.title": "Helpful Identification Card Tips",
"id.verification.id.tips.description": "Next, we'll need you to take a photo of a valid identification card that includes your full name and photo, such as a drivers license or passport. Please have your ID ready.",
"id.verification.id.tips.list.well.lit": "Your identification card is well-lit.",
"id.verification.camera.help.difficulty.answer": "إذا كنت بحاجة إلى المساعدة في التقاط صورة للتقديم، فاتصل بدعم edX للحصول على اقتراحات إضافية.",
"id.verification.camera.help.upload.question": "What if I want to upload a photo instead?",
"id.verification.camera.help.upload.answer": "On the next page you will have the option to switch to upload mode. By selecting that option, you will be able to upload a photo instead.",
"id.verification.id.photo.unclear.question": "هل صورة بطاقة الهوية غير واضحة أو ضبابية؟",
"id.verification.id.tips.title": "تلميحات مفيدة للهوية ",
"id.verification.id.tips.description": "بعد ذلك، سنكون بحاجة إلى التقاط صورة لبطاقة هوية صالحة تتضمن اسمك الكامل وصورتك. يرجى تجهيز بطاقة هويتك.",
"id.verification.id.tips.list.well.lit": "إضاءة بطاقة هويتك جيدة.",
"id.verification.id.tips.list.clear": "تأكد من قدرتك على رؤية صورتك وقراءة اسمك بوضوح.",
"id.verification.id.photo.title.camera": "Take a Photo of Your Identification Card",
"id.verification.id.photo.title.upload": "Upload a Photo of Your Identification Card",
"id.verification.id.photo.title.camera": "التقط صورة لبطاقتك الشخصية",
"id.verification.id.photo.title.upload": "حمّل صورة لهويتك",
"id.verification.id.photo.preview.alt": "معاينة صورة الهوية.",
"id.verification.id.photo.instructions.camera": "When your ID is in position, use the Take Photo button below to take your photo. Please use a passport, drivers license, or another identification card that includes your full name and a picture of your face.",
"id.verification.id.photo.instructions.upload": "Please upload a photo of your identification card. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ",
"id.verification.id.photo.instructions.camera": "عندما تكون بطاقة هويتك في موضعها، استخدم زر التقاط صورة أدناه لالتقاط الصورة.",
"id.verification.id.photo.instructions.upload": "Please upload an ID photo. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ",
"id.verification.id.photo.instructions.upload.error.invalidFileType": "The file you have selected is not a supported image type. Please choose from the following formats: ",
"id.verification.id.photo.instructions.upload.error.fileTooLarge": "The file you have selected is too large. Please try again with a file less than 10MB.",
"id.verification.name.check.title": "Double-Check Your Name",
"id.verification.name.check.instructions": "Does the name below match the name on your photo ID? If not, update the name below to match your photo ID.",
"id.verification.name.check.mismatch.information": "If the name below does not match your photo ID, your identity verification will be denied.",
"id.verification.name.error": "Please enter your name as it appears on your photo ID.",
"id.verification.account.name.title": "التحقق من اسم الحساب",
"id.verification.account.name.instructions": "يجب أن يكون الاسم الموجود على حسابك والاسم الموجود على المعرّف الخاص بك متطابقًا تمامًا. إذا لم يكن الأمر كذلك، فيرجى النقر فوق \"لا\" لتحديث اسم حسابك.",
"id.verification.account.name.radio.label": "هل يتطابق الاسم الموجود على هويتك مع اسم الحساب أدناه؟",
"id.verification.account.name.radio.yes": "نعم",
"id.verification.account.name.radio.no": "لا",
"id.verification.account.name.error": "يرجى تحديث اسم الحساب لمطابقة الاسم على بطاقة الهوية.",
"id.verification.account.name.warning.prefix": "يُرجى الملاحظة:",
"id.verification.account.name.settings": "إعدادات الحساب",
"id.verification.name.label": "Name",
"id.verification.account.name.label": "اسم الحساب",
"id.verification.account.name.photo.alt": "صورة من هويتك للتقديم.",
"id.verification.account.name.save": "حفظ ثم التالي",
"id.verification.review.title": "مراجعة صورك",
"id.verification.review.description": "يُرجى التأكّد من أنّ الصور والمعلومات التي قدّمتها تمكّننا من التحقّق من هويّتك. ",
"id.verification.review.portrait.label": "صورتك الشخصية",
"id.verification.review.portrait.alt": "صورة شخصية للتقديم.",
"id.verification.review.portrait.retake": "إعادة التقاط الصورة شخصية",
"id.verification.review.id.label": "Your Identification Card",
"id.verification.review.id.alt": "Photo of your identification card to be submitted.",
"id.verification.review.id.label": "معرّف صورتك",
"id.verification.review.id.alt": "صورة من هويتك للتقديم.",
"id.verification.review.id.retake": "إعادة التقاط صورة الهوية",
"id.verification.review.confirm": "إرسال",
"id.verification.submission.alert.error.face": "مطلوب صورة لوجهك. يرجى إعادة التقاط الصورة الشخصية.",
"id.verification.submission.alert.error.id": "مطلوب صورة لبطاقة هويتك. يرجى إعادة التقاط صورة بطاقة الهوية.",
"id.verification.submission.alert.error.name": "مطلوب اسم حساب صالح. يرجى تحديث اسم حسابك لمطابقة الاسم على هويتك.",
"id.verification.submission.alert.error.unsupported": "One or more of the files you have uploaded is in an unsupported format. Please choose from the following: ",
"id.verification.review.error": "{siteName} Support Page",
"id.verification.review.error": "صفحة فريق دعم edX ",
"id.verification.submitted.title": "جارِ التحقق من الهوية",
"id.verification.submitted.text": "We have received your information and are verifying your identity. You will be notified when the verification process is complete (usually within 5 days). In the meantime, you can still access all available course content.",
"id.verification.submitted.text": "لقد تلقينا معلوماتك وجاري الآن العمل على التحقق من هويتك. ستصلك رسالة على لوحة المعلومات عند اكتمال عملية التحقق (عادةً خلال 5 أيام). في غضون ذلك، لا يزال بإمكانك الوصول إلى كل محتوى المساق المتوفر.",
"id.verification.return.dashboard": "العودة إلى لوحة المعلومات",
"id.verification.return.course": "العودة للمساق",
"id.verification.return.generic": "Return",
"id.verification.photo.upload.help.title": "Upload a Photo Instead",
"id.verification.photo.camera.help.title": "Use Your Camera Instead",
"id.verification.photo.upload.help.text": "If you are having trouble using the photo capture above, you may want to upload a photo instead. To upload a photo, click the button below.",
"id.verification.photo.camera.help.text": "If you are having trouble uploading a photo above, you may want to use your camera instead. To use your camera, click the button below.",
"id.verification.upload.help.button": "Switch to Upload Mode",
"id.verification.camera.help.button": "Switch to Camera Mode",
"id.verification.choose.mode.title": "Photo Requirements Options",
"id.verification.choose.mode.hep.text": "To complete verification, please select one of the following options to submit photos. You will be able to switch between these options throughout the process if needed.",
"id.verification.choose.mode.radio.upload": "Upload photos from my device",
"id.verification.choose.mode.radio.camera": "Take pictures using my camera",
"id.verification.account.name.managed.alert": "Your profile settings are managed by {managerTitle}, so you are not allowed to update your name. Please contact your {profileDataManager} administrator or {support} for help.",
"id.verification.request.camera.access.instructions": "لالتقاط صورة باستخدام كاميرا الويب، قد تتلقى طلب المتصفح للوصول إلى الكاميرا. {clickAllow}",
"id.verification.requirements.account.managed.alert": "Your account settings are managed by {managerTitle}. If the name on your photo ID does not match the name on your account, please contact your {profileDataManager} administrator or {support} for help before completing the Photo Verification process.",
"id.verification.requirements.card.device.text": "أنت بحاجة إلى جهاز مزود بكاميرا. إذا تلقيت طلب المتصفح للوصول إلى الكاميرا، فيرجى التأكد من النقر فوق {السماح}.",

View File

@@ -1,5 +1,5 @@
{
"account.settings.message.duplicate.tpa.provider": "La cuenta de {provider} seleccionada ya está vinculada con otra cuenta de {siteName}. ",
"account.settings.message.duplicate.tpa.provider": "La cuenta de {provider} seleccionada ya está vinculada con otra cuenta de edX. ",
"account.settings.message.managed.settings": "Los ajustes en el perfil son administrados por {managerTitle}. Contacte su administrador o {support} para obtener ayuda.",
"account.settings.message.managed.settings.support": "soporte",
"account.settings.page.heading": "Configuración de cuenta",
@@ -14,40 +14,16 @@
"account.settings.section.demographics.information": "Información opcional",
"account.settings.section.site.preferences": "Preferencias del sitio",
"account.settings.section.linked.accounts": "Cuentas vinculadas",
"account.settings.section.linked.accounts.description": "Puedes vincular tus cuentas de redes sociales para simplificar el proceso de iniciar sesión en {siteName}.",
"account.settings.section.linked.accounts.description": "Puedes vincular tus cuentas de redes sociales para simplificar el proceso de iniciar sesión en edX.",
"account.settings.field.username": "Nombre de usuario",
"account.settings.field.username.help.text": "El nombre que lo identifica en {siteName}. No podrá cambiar el nombre de usuario.",
"account.settings.field.username.help.text": "El nombre que lo identifica en edX. No podrá cambiar el nombre de usuario.",
"account.settings.field.full.name": "Nombre completo",
"account.settings.field.full.name.empty": "Añade nombre",
"account.settings.field.full.name.help.text": "El nombre que es usado para la verificación de identidad y aparece en sus certificados.",
"account.settings.field.full.name.help.text.default": "El nombre que aparece en tu perfil público.",
"account.settings.field.full.name.help.text.default.certificate": "Este nombre está seleccionado para aparecer en tus certificados y registros públicos.",
"account.settings.field.name.verified": "Nombre verificado",
"account.settings.field.name.verified.help.text.verified": "Este nombre ha sido verificado por una identificación con foto.",
"account.settings.field.name.verified.help.text.verified.proctored": "Este nombre ha sido verificado por supervisión.",
"account.settings.field.name.verified.help.text.verified.certificate": "Este nombre ha sido verificado por una identificación con foto y está seleccionado para aparecer en sus certificados y registros públicos.",
"account.settings.field.name.verified.help.text.verified.proctored.certificate": "Este nombre se ha verificado mediante supervisión y se ha seleccionado para que aparezca en sus certificados y registros públicos.",
"account.settings.field.name.verified.help.text.submitted": "La verificación ha sido enviada. Este proceso usualmente toma 48 horas o menos. El nombre verificado no puede ser modificado en este momento. ",
"account.settings.field.name.verified.help.text.submitted.proctored": "Su examen supervisado ha sido enviado. El nombre verificado no se puede cambiar en este momento. Vuelva a consultar en 2-5 días.",
"account.settings.field.name.verified.help.text.submitted.certificate": "Cuando el proceso de verificación de identidad sea exitoso, este nombre aparecerá en tus certificados y registros públicos. El nombre verificado no puede ser cambiado en este momento. ",
"account.settings.field.name.verified.help.text.submitted.proctored.certificate": "Una vez que su examen supervisado pase la revisión, este nombre aparecerá en su certificado y en los registros públicos. El nombre verificado no se puede cambiar en este momento.",
"account.settings.field.name.verified.verification.help": "Ingrese su nombre tal como aparece en su tarjeta de identificación vigente de estudiante, trabajo o emitida por el gobierno.",
"account.settings.field.full.name.help.text.submitted": "La verificación ha sido enviada. Este proceso usualmente toma 48 horas o menos. El nombre verificado no puede ser modificado en este momento. ",
"account.settings.field.full.name.help.text.submitted.proctored": "Su examen supervisado ha sido enviado. El nombre completo no se puede cambiar en este momento. Vuelva a consultar en 2-5 días.",
"account.settings.field.full.name.help.text.submitted.certificate": "En cuanto el proceso de verificación de identidad sea exitoso, este nombre aparecerá en tus certificados y registros públicos. El nombre completo no puede ser cambiado en este momento. ",
"account.settings.field.full.name.help.text.submitted.proctored.certificate": "Una vez que su examen supervisado pase la revisión, este nombre aparecerá en sus certificados y registros públicos. El nombre completo no se puede cambiar en este momento.",
"account.settings.field.name.verified.success.message": "Tu solicitud de verificación de identidad se ha completado exitosamente. Ahora puedes seleccionar el nombre prefieres que aparezca en tus certificados y registros públicos.",
"account.settings.field.name.verified.success.message.header": "¡Tu solicitud de cambio de nombre está completada!",
"account.settings.field.name.verified.failure.message": "Tu más reciente intento de verificación de ID no fue aprobado. Las configuraciones relacionadas con el proceso se han restablecido.",
"account.settings.field.name.verified.failure.message.header": "No pudimos verificar tu identidad.",
"account.settings.field.name.verified.failure.message.help.link": "Más información sobre la verificación de ID",
"account.settings.field.name.verified.submitted.message": "Tu solicitud de verificación de identidad ha sido enviada y usualmente se tarda entre 24 a 48 horas para completarse. ",
"account.settings.field.name.verified.submitted.message.certificate": "Cuando la solicitud esté aprobada, la actualización de tu nombre aparecerá en todos los certificados asociados y registros públicos.",
"account.settings.field.name.verified.submitted.message.header": "¡Tu solicitud de cambio de nombre esta por completarse!",
"account.settings.field.email": "Correo electrónico (Ingresar)",
"account.settings.field.email.empty": "Agregar correo electrónico",
"account.settings.field.email.confirmation": "Le enviamos un mensaje de confirmación a {value}. Hacer click en la liga del mensaje para actualizar su correo electrónico.",
"account.settings.field.email.help.text": "Tienes un mensaje de {siteName} y el equipo de curso en esta dirección. ",
"account.settings.field.email.help.text": "Recibes mensajes de edX y equipos del curso en esta dirección.",
"account.settings.field.secondary.email": "Correo electrónico de recuperación",
"account.settings.field.secondary.email.empty": "Agregar un correo electrónico de recuperación",
"account.settings.field.secondary.email.confirmation": "Le enviamos un mensaje de confirmación a {value}. Hacer click en la liga del mensaje para actualizar su correo electrónico.",
@@ -91,7 +67,7 @@
"account.settings.field.time.zone.all": "Todas las zonas horarias",
"account.settings.field.time.zone.country": "Zonas horarias",
"account.settings.section.social.media": "Enlaces de redes sociales",
"account.settings.section.social.media.description": "Opcionalmente, conecte sus cuentas personales a los iconos de redes sociales en su perfil de {siteName}.",
"account.settings.section.social.media.description": "Opcionalmente, conecte sus cuentas personales a los iconos de redes sociales en su perfil de edX.",
"account.settings.field.social.platform.name.linkedin": "LinkedIn",
"account.settings.field.social.platform.name.linkedin.empty": "Agregar perfil de LinkedIn",
"account.settings.jump.nav.delete.account": "Eliminar mi cuenta",
@@ -104,12 +80,6 @@
"account.settings.editable.field.action.edit": "Editar",
"account.settings.static.field.empty": "No hay valor establecido. Contacte su administrador {enterprise} para hacer cambios.",
"account.settings.static.field.empty.no.admin": "No hay valor establecido.",
"account.settings.field.name.certificate.select": "En caso de ser seleccionado, este nombre aparecerá en tus certificados y registros públicos. ",
"account.settings.field.name.modal.certificate.title": "Escoge un nombre de preferencia para tus certificados y registros públicos.",
"account.settings.field.name.modal.certificate.select": "Selecciona un nombre",
"account.settings.field.name.modal.certificate.option.full": "Nombre completo",
"account.settings.field.name.modal.certificate.option.verified": "Nombre verificado",
"account.settings.field.name.modal.certificate.button.choose": "Escoge un nombre",
"account.settings.coaching.consent.welcome.header": "Empecemos",
"account.settings.coaching.consent.welcome.subheader": "Estamos aquí para ustede desde el inicio hasta el final",
"account.settings.coaching.consent.description": "Los programas de MicroBachelors incluyen entrenamiento que se enfoca en su carrera, educación y cómo logrará resultados a través de la comunicación individual con un profesional experimentado. Si está interesado, proporcione la información a continuación y haga clic en \"Enviar\", y nuestro socio asesor se comunicará con usted por correo electrónico y / o mensaje de texto para ayudarlo a avanzar. Los términos y Condiciones aplican.*",
@@ -131,20 +101,17 @@
"account.settings.delete.account.before.proceeding": "Antes de continuar, por favor {actionLink}.",
"account.settings.delete.account.header": "Eliminar mi cuenta",
"account.settings.delete.account.subheader": "¡Sentimos que te vayas!",
"account.settings.delete.account.text.1": "Para tener en cuenta: La eliminación de su cuenta y sus datos personales es permanente y no se puede deshacer. {siteName} no podrá recuperar tu cuenta o la información que sea eliminada.",
"account.settings.delete.account.text.2": "Una véz tu cuenta haya sido eliminada, no podrás usarla para tomar cursos en {siteName}. ",
"account.settings.delete.account.text.2.edX": "Una vez su cuenta haya sido eliminada, no la podrá usar para tomar cursos en la app de edX, edx.org o en cualquier otro sitio administrado por edX. Esto incluye el acceso a edx.org desde el sistema de su empleador o universidad y el acceso a páginas privadas ofrecidas por MIT Open Learning, Wharton Executive Education y Harvard Medical School.",
"account.settings.delete.account.text.3.link": "Sigue estas instrucciones para imprimir o descargar un certificado. ",
"account.settings.delete.account.text.warning": "Atención: la eliminación de su cuenta es permanente. Por favor lea el previo aviso cautelosamente antes de proceder, ya que esto es una acción irreversible, y no podrá usar el mismo correo electrónico en {siteName}.",
"account.settings.delete.account.text.1": "Cuidado: la eliminación de tu cuenta y datos personales es permanente e irreversible. edX no podrá recuperar ni tu cuenta ni los datos eliminados.",
"account.settings.delete.account.text.2": "Una vez su cuenta haya sido eliminada, no la podrá usar para tomar cursos en la app de edX, edx.org o en cualquier otro sitio administrado por edX. Esto incluye el acceso a edx.org desde el sistema de su empleador o universidad y el acceso a páginas privadas ofrecidas por MIT Open Learning, Wharton Executive Education y Harvard Medical School.",
"account.settings.delete.account.text.3.link": "siga las instrucciones para imprimir o descargar el certificado",
"account.settings.delete.account.text.warning": "Warning: La eliminación de la cuenta es permanente. Por favor lee la información de más arriba con atención antes de proceder. Esta es una acción irreversible, y no podrás volver a usar el mismo correo electrónico en edX.",
"account.settings.delete.account.text.change.instead": "En lugar de eso, ¿quieres cambiar tu correo electrónico, nombre o contraseña?",
"account.settings.delete.account.button": "Eliminar mi cuenta",
"account.settings.delete.account.please.activate": "activar su cuenta",
"account.settings.delete.account.please.confirm": "Confirma tu cuenta",
"account.settings.delete.account.please.unlink": "Desvincular todas las cuentas de redes sociales.",
"account.settings.delete.account.modal.header": "¿Está seguro?",
"account.settings.delete.account.modal.text.1": "Has seleccionado la opción ''Eliminar mi cuenta''. Ten en cuenta que la eliminación de tu cuenta e información personal es permanente y no puede revertirse. {siteName} no será capaz de recuperar tu cuenta o información una vez esta sea eliminada. ",
"account.settings.delete.account.modal.text.2": "Si aceptas proceder, ya no podrás usar esta cuenta para tomar cursos en {siteName}.",
"account.settings.delete.account.modal.text.2.edX": "Si procedes, no será posible usar esta cuenta para tomar cursos ni en la aplicación móvil de edX, ni en edx.org, ni en cualquier otro sitio hospedado por edX. Esto incluye el acceso a edx.org desde el sistema de tu empleador o universidad, y el acceso a sitios privados ofrecidos por MIT Open Learning, Wharton Executive Education, y Harvard Medical School.",
"account.settings.delete.account.modal.text.1": "Has seleccionado Eliminar mi cuenta”. La eliminación de tu cuenta y datos personales es permanente e irreversible. edX no será capaz de recuperar tu cuenta o los datos que se hayan borrado.",
"account.settings.delete.account.modal.text.2": "Si procedes, no será posible usar esta cuenta para tomar cursos ni en la aplicación móvil de edX, ni en edx.org, ni en cualquier otro sitio hospedado por edX. Esto incluye el acceso a edx.org desde el sistema de tu empleador o universidad, y el acceso a sitios privados ofrecidos por MIT Open Learning, Wharton Executive Education, y Harvard Medical School.",
"account.settings.delete.account.modal.enter.password": "Si deseas continuar y eliminar tu cuenta, por favor introduce la contraseña de tu cuenta:",
"account.settings.delete.account.modal.confirm.delete": "Si, Eliminar",
"account.settings.delete.account.modal.confirm.cancel": "Cancelar",
@@ -155,8 +122,7 @@
"account.settings.delete.account.modal.after.header": "¡Sentimos que te vayas! Tu cuenta será eliminada en breve.",
"account.settings.delete.account.modal.after.text": "La eliminación de cuenta, incluyendo la eliminación de las listas de correo electrónico, puede tardar unas semanas en procesarse totalmente en nuestro sistema. Si quieres renunciar a recibir correos antes de que la eliminación se haya completado, por favor date de baja mediante el enlace que aparece al final de los correos.",
"account.settings.delete.account.modal.after.button": "Cerrar",
"account.settings.delete.account.text.3.edX": "También podrás perder el acceso a certificados verificados y a otras credenciales del programa como los certificados MicroMasters. Puedes hacer una copia de estos registros antes de proceder con la eliminación. {actionLink}.",
"account.settings.delete.account.text.3": "También podrás perder el acceso a certificados verificados y a otras credenciales del programa. Puedes hacer una copia de estos registros antes de proceder con la eliminación. ",
"account.settings.delete.account.text.3": "Puede que también pierdas el acceso a los certificados verificados y otros certificados de programas como los de los MicroMasters. Si quieres hacer una copia de dichos certificados para tus archivos antes de proceder a la eliminación, {actionLink}.",
"account.settings.message.demographics.service.issue": "Ocurrió un error al intentar recuperar o guardar la información de tu cuenta. Por favor inténtalo más tarde.",
"account.settings.field.demographics.gender": "Identidad de género",
"account.settings.field.demographics.gender.empty": "Añade identidad de género",
@@ -187,17 +153,7 @@
"account.settings.field.demographics.future_work_sector": "Área profesional futura",
"account.settings.field.demographics.future_work_sector.empty": "Añade área profesional",
"account.settings.field.demographics.work_sector.options.empty": "Selecciona área profesional",
"account.settings.section.demographics.why": "¿Por qué {siteName} recauda esta información?",
"account.settings.name.change.title.id": "Este cambio de nombre requiere de verificación de identidad.",
"account.settings.name.change.title.begin": "Antes de empezar",
"account.settings.name.change.warning.one": "Atención: esta acción actualizará el nombre que aparece en todos los certificados obtenidos a través de esta cuenta en el pasado y en aquellos en que actualmente estás obteniendo o que adquirirás en el futuro.",
"account.settings.name.change.warning.two": "Esta acción no podrá revertirse sin antes verificar tu identidad.",
"account.settings.name.change.id.name.label": "Ingrese su nombre tal como aparece en su tarjeta de identificación vigente de estudiante, trabajo o emitida por el gobierno.",
"account.settings.name.change.id.name.placeholder": "Enter the name on your photo ID",
"account.settings.name.change.error.valid.name": "Por favor ingresa un nombre valido.",
"account.settings.name.change.error.general": "Ha ocurrido un error técnico. Por favor intentalo de nuevo.",
"account.settings.name.change.continue": "Continuar",
"account.settings.name.change.cancel": "Cancelar",
"account.settings.section.demographics.why": "¿Por qué edX obtiene esta información?",
"error.notfound.message": "La página que estas buscando no está disponible o hay un error en la URL. Por favor, comprueba la URL y vuelve a intentarlo.",
"account.settings.editable.field.password.reset.button.confirmation.support.link": "soporte técnico",
"account.settings.editable.field.password.reset.button.confirmation": "Hemos mandado un mensaje a {email}. Haz clic en el enlace en el mensaje para restablecer tu contraseña. ¿No recibiste el mensaje? Contáctate con {technicalSupportLink}.",
@@ -209,26 +165,26 @@
"account.settings.sso.account.disconnect.error": "Hubo un problema al desconectar esta Cuenta. Si el problema persiste, contacte soporte.",
"account.settings.sso.unlink.account": "Desvincular la cuenta de {accountName} ",
"account.settings.sso.no.providers": "No se pueden vincular cuentas en este momento.",
"id.verification.access.blocked.denied": "No podemos verificar tu identidad en este momento. Si aún no has activado tu cuenta, comprueba en tu carpeta de correo no deseado el correo de activación de {email}.",
"id.verification.access.blocked.denied": "No puedes verificar tu identidad en este momento. Si aún tienes que activar tu cuenta, revisa tu carpeta de correo no deseado y busca el correo electrónico de activación de {email}.",
"id.verification.next": "Siguiente",
"id.verification.support": "soporte",
"id.verification.support": "support",
"id.verification.example.card.alt": "Ejemplo de un documento de identidad válido con foto y nombre completo.",
"id.verification.requirements.title": "Requerimientos de verificación por foto",
"id.verification.requirements.description": "Para completa la verificación de foto, necesitarás lo siguiente:",
"id.verification.requirements.description": "Para completar la verificación por foto en línea, necesitarás lo siguiente:",
"id.verification.requirements.card.device.title": "Dispositivo con cámara",
"id.verification.requirements.card.device.allow": "Permitir",
"id.verification.requirements.card.id.title": "Tarjeta de identificación con foto",
"id.verification.requirements.card.id.text": "Necesitas una tarjeta de identificación válida que contenga tu nombre completo y foto, similar a una licencia de conducir o pasaporte.",
"id.verification.requirements.card.id.title": "Identificación por foto",
"id.verification.requirements.card.id.text": "Necesitas un documento de identidad válido que contenga tu foto y nombre completo.",
"id.verification.privacy.title": "Información de privacidad",
"id.verification.privacy.need.photo.question": "¿Por qué {siteName} necesita mi foto?",
"id.verification.privacy.need.photo.question": "¿Por qué edX necesita mi foto?",
"id.verification.privacy.need.photo.answer": "Utilizamos tus fotos de verificación para confirmar tu identidad y garantizar la validez de tu certificado.",
"id.verification.privacy.do.with.photo.question": "¿Qué hace {siteName} con esta foto?",
"id.verification.privacy.do.with.photo.answer": "Encriptamos de forma segura tu foto y la enviamos a nuestro servicio de autorización para su revisión. Tu foto e información no se guardan ni se ven en ninguna parte de {siteName} después de que se completa el proceso de verificación.",
"id.verification.privacy.do.with.photo.question": "¿Qué hace edX con esta foto?",
"id.verification.privacy.do.with.photo.answer": "Encriptamos de forma segura tu foto y la enviamos a nuestro servicio de autorización para su revisión. Tu foto e información no se guardan ni se ven en ninguna parte de edX después de que se completa el proceso de verificación.",
"id.verification.access.blocked.title": "Verificación de identidad",
"id.verification.access.blocked.enrollment": "Actualmente, no estás inscrito en un curso que requiera verificación de identidad.",
"id.verification.access.blocked.pending": "Ya has enviado tu información de verificación de identidad. Recibirás un mensaje en tu panel principal cuando el proceso de verificación esté completado (usualmente dentro de los 5 días).",
"id.verification.photo.take": "Tomar la foto",
"id.verification.photo.retake": "¿Tomar nuevamente la foto?",
"id.verification.photo.retake": "Retake Photo?",
"id.verification.photo.enable.detection": "Habilitar la detección de rostro",
"id.verification.photo.enable.detection.portrait.help.text": "Si está marcada, aparecerá un cuadro alrededor de tu cara. Tu rostro se puede ver claramente si el cuadro que lo rodea es azul. Si Tu cara no está en una buena posición o es indetectable, el cuadro será rojo.",
"id.verification.photo.enable.detection.id.help.text": "Si está marcada, aparecerá una casilla alrededor de la cara de tu documento de identificación. La cara se puede ver claramente si la caja que la rodea es azul. Si la cara no está en una buena posición o es indetectable, el cuadro será rojo.",
@@ -279,9 +235,9 @@
"id.verification.camera.access.failure.temporary.safari.step2": "Haz clic en el menú de la aplicación Safari y luego selecciona \"Preferencias\". También puedes utilizar Command +, como método abreviado de teclado.",
"id.verification.camera.access.failure.temporary.safari.step3": "Selecciona la pestaña \"Sitios web\" y luego selecciona \"Cámara\".",
"id.verification.camera.access.failure.temporary.safari.step4": "Selecciona \"edx.org\" y cambia los permisos de la cámara a \"Permitir.\"",
"id.verification.camera.access.failure.unsupported": "Parece que tu navegar no soporta el acceso a la cámara.",
"id.verification.camera.access.failure.unsupported.chrome.explanation": "El navegador de Chrome actualmente no soporta el acceso a la cámara en dispositivos iOS, como iPhones y iPads.",
"id.verification.camera.access.failure.unsupported.instructions": "Por favor utilice otro navegador para completar la verificación de identidad.",
"id.verification.camera.access.failure.unsupported": "It looks like your browser does not support camera access.",
"id.verification.camera.access.failure.unsupported.chrome.explanation": "The Chrome browser currently does not support camera access on iOS devices, such as iPhones and iPads.",
"id.verification.camera.access.failure.unsupported.instructions": "Please use another browser to complete Identity Verification.",
"id.verification.photo.tips.title": "Consejos útiles de fotos",
"id.verification.photo.tips.description": "A continuación, necesitaremos que tomes una foto de tu rostro. Por favor, revisa los siguientes consejos útiles.",
"id.verification.photo.tips.list.title": "Consejos para fotos",
@@ -289,62 +245,74 @@
"id.verification.photo.tips.list.well.lit": "El rostro esté bien iluminado",
"id.verification.photo.tips.list.inside.frame": "Tu cara está completamente dentro del marco de la foto.",
"id.verification.portrait.photo.title.camera": "Toma una foto de ti mismo",
"id.verification.portrait.photo.title.upload": "Sube una foto tuya",
"id.verification.portrait.photo.preview.alt": "Previsualización de la foto con el rostro del usuario",
"id.verification.portrait.photo.instructions.camera": "Cuando tu rostro esté en posición, usa el botón Tomar foto a continuación para tomar tu foto.",
"id.verification.portrait.photo.instructions.upload": "Please upload a portrait photo. Ensure your entire face fits inside the frame and is well-lit. Supported formats: ",
"id.verification.camera.help.sight.question": "¿Qué pasa si no puedo ver la imagen de la cámara o si no puedo ver mi foto para determinar qué lado es visible?",
"id.verification.camera.help.sight.answer.portrait": "Es posible que puedas completar el procedimiento de captura de imágenes sin ayuda, pero es posible que necesites un par de intentos de envío para que la cámara se coloque correctamente. La posición óptima de la cámara varía con cada computadora, pero generalmente la mejor distancia para una foto de rostro es aproximadamente a 12 a 18 pulgadas (30 a 45 centímetros) de la cámara, con la cabeza centrada en relación con la pantalla de la computadora. Si las fotos que envías son rechazadas, intenta mover la computadora o la orientación de la cámara para cambiar el ángulo de iluminación.",
"id.verification.camera.help.sight.answer.id": "Es posible que puedas completar el procedimiento de captura de imágenes sin ayuda, pero es posible que necesites un par de intentos de envío para que la cámara se coloque correctamente. El posicionamiento óptimo de la cámara varía con cada computadora pero, generalmente, la mejor distancia para una foto de un documento de identificación es a 8 a 12 pulgadas (20 a 30 centímetros) de la cámara, con el documento de identificación centrado en relación con la cámara. Si las fotos que envías son rechazadas, intenta mover la computadora o la orientación de la cámara para cambiar el ángulo de iluminación. La razón más común de rechazo es la imposibilidad de leer el texto del documento de identidad.",
"id.verification.camera.help.difficulty.question.portrait": "¿Qué sucede si tengo dificultades para mantener la cabeza en posición con respecto a la cámara?",
"id.verification.camera.help.difficulty.question.id": "¿Qué sucede si tengo dificultades para mantener mi identificación en posición con respecto a la cámara?",
"id.verification.camera.help.difficulty.answer": "Si requieres de asistencia para cargar una foto, contacta el equipo de soporte de {siteName} para sugerencias adicionales.",
"id.verification.id.photo.unclear.question": "¿No es clara la foto de tu tarjeta de ID ó es muy borrosa?",
"id.verification.id.tips.title": "Tips de ayuda para tarjetas de identificación",
"id.verification.id.tips.description": "Después, necesitaremos que tomes una foto de una tarjeta de ID válida que tengas en la que incluya tu nombre completo y foto, como una licencia de conducción o un pasaporte. Por favor ten tú ID lista.",
"id.verification.id.tips.list.well.lit": "Tu tarjeta de identificación está bien iluminada.",
"id.verification.camera.help.difficulty.answer": "Si necesitas ayuda para tomar una foto para enviarla, comunícate con soporte de edX para obtener sugerencias adicionales.",
"id.verification.camera.help.upload.question": "What if I want to upload a photo instead?",
"id.verification.camera.help.upload.answer": "On the next page you will have the option to switch to upload mode. By selecting that option, you will be able to upload a photo instead.",
"id.verification.id.photo.unclear.question": "¿La imagen de tu identificación no es clara o está demasiado borrosa?",
"id.verification.id.tips.title": "Consejos útiles para la verificación",
"id.verification.id.tips.description": "A continuación, necesitaremos que tomes una foto de un documento de identidad válido que incluya tu foto y nombre completo. Ten tu ID a mano.",
"id.verification.id.tips.list.well.lit": "Tu identificación está bien iluminada.",
"id.verification.id.tips.list.clear": "Asegúrate de que puedes ver tu foto y leer claramente tu nombre.",
"id.verification.id.photo.title.camera": "Toma una foto de tu tarjeta de identificación",
"id.verification.id.photo.title.upload": "Sube una foto de tu tarjeta de identificación",
"id.verification.id.photo.title.camera": "Toma una Foto de tu Identificación",
"id.verification.id.photo.title.upload": "Carga una foto de tu identificación",
"id.verification.id.photo.preview.alt": "Previsualización de Foto ID",
"id.verification.id.photo.instructions.camera": "Cuando tú ID este en posición, utiliza el botón de tomar foto que se encuentra debajo para proceder a tomar tu foto. Por favor usa un pasaporte, licencia de conducción, u otra tarjeta de identificación que incluya tu nombre completo y una foto de tu rostro.",
"id.verification.id.photo.instructions.upload": "Por favor sube una foto de tu identificación. Asegurate de que todo el ID encaje dentro de el marco y que este bien iluminado. El tamaño del archivo debe ser por debajo de 10 MB. Los formatos soportados son: ",
"id.verification.id.photo.instructions.upload.error.invalidFileType": "El archivo que has seleccionado no cumple con el tipo de imágenes soportadas. Por favor escoge entre los siguientes formatos:",
"id.verification.id.photo.instructions.upload.error.fileTooLarge": "El archivo que has seleccionado es demasiado grande. Vuelve a intentarlo con un archivo de menos de 10 MB.",
"id.verification.name.check.title": "Revisa nuevamente tu nombre",
"id.verification.name.check.instructions": "Does the name below match the name on your photo ID? If not, update the name below to match your photo ID.",
"id.verification.name.check.mismatch.information": "Si el nombre a continuación no coincide con su identificación con foto, se denegará su verificación de identidad.",
"id.verification.name.error": "Ingrese su nombre tal como aparece en su identificación con foto.",
"id.verification.id.photo.instructions.camera": "Cuando tu identificación esté en su lugar, usa el botón Tomar foto a continuación para tomar tu foto.",
"id.verification.id.photo.instructions.upload": "Please upload an ID photo. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ",
"id.verification.id.photo.instructions.upload.error.invalidFileType": "The file you have selected is not a supported image type. Please choose from the following formats: ",
"id.verification.id.photo.instructions.upload.error.fileTooLarge": "The file you have selected is too large. Please try again with a file less than 10MB.",
"id.verification.account.name.title": "Verificación de nombre de cuenta",
"id.verification.account.name.instructions": "El nombre de tu cuenta y el nombre de tu identificación deben coincidir exactamente. De lo contrario, haz clic en \"No\" para actualizar el nombre de tu cuenta.",
"id.verification.account.name.radio.label": "¿El nombre de tu identificación coincide con el nombre de la cuenta a continuación?",
"id.verification.account.name.radio.yes": "Si",
"id.verification.account.name.radio.no": "No",
"id.verification.account.name.error": "Actualiza el nombre de la cuenta para que coincida con el nombre de tu identificación.",
"id.verification.account.name.warning.prefix": "Ten en cuenta:",
"id.verification.account.name.settings": "Configuración de cuenta",
"id.verification.name.label": "Nombre",
"id.verification.account.name.label": "Nombre de la cuenta",
"id.verification.account.name.photo.alt": "Foto de tu identificación a enviar.",
"id.verification.account.name.save": "Guardar y siguiente",
"id.verification.review.title": "Revisar tus fotos",
"id.verification.review.description": "Asegúrate de que podamos verificar tu identidad con las imágenes y la información suministradas.",
"id.verification.review.portrait.label": "Tu Retrato",
"id.verification.review.portrait.alt": "Foto de tu rostro a enviar.",
"id.verification.review.portrait.retake": "Retomar Foto de Retrato",
"id.verification.review.id.label": "Tú tarjeta de identificación",
"id.verification.review.id.alt": "Foto de tu tarjeta de identificación para ser enviada.",
"id.verification.review.id.label": "Tu Foto de identificación",
"id.verification.review.id.alt": "Foto de tu identificación a enviar.",
"id.verification.review.id.retake": "Retomar Foto de identificación",
"id.verification.review.confirm": "Enviar",
"id.verification.submission.alert.error.face": "Se requiere una foto de tu rostro. Vuelve a tomar tu foto de retrato.",
"id.verification.submission.alert.error.id": "Se requiere una foto de tu documento de ID. Vuelve a tomar tu foto de ID.",
"id.verification.submission.alert.error.name": "Se requiere un nombre de cuenta válido. Actualiza el nombre de tu cuenta para que coincida con el nombre que figura en tu ID.",
"id.verification.submission.alert.error.unsupported": "Uno o más de los siguientes archivos que has subido se encuentran en un formato no soportado. Por favor escoge de los siguientes formatos:",
"id.verification.review.error": "Página de soporte de {siteName}",
"id.verification.submission.alert.error.unsupported": "One or more of the files you have uploaded is in an unsupported format. Please choose from the following: ",
"id.verification.review.error": "Página de soporte de edX",
"id.verification.submitted.title": "Verificación de identidad en progreso.",
"id.verification.submitted.text": "Hemos recibido su información y estamos verificando su identidad. Se le notificará cuando se complete el proceso de verificación (generalmente dentro de los 5 días). Mientras tanto, aún puede acceder a todo el contenido del curso disponible.",
"id.verification.submitted.text": "Hemos recibido tu información y estamos verificando tu identidad. Verás un mensaje en tu tablero cuando se complete el proceso de verificación (generalmente en un periodo de 5 días). Mientras tanto, aún puedes acceder a todo el contenido del curso disponible.",
"id.verification.return.dashboard": "Volver al panel principal",
"id.verification.return.course": "Regresar al curso",
"id.verification.return.generic": "Regresar",
"id.verification.photo.upload.help.title": "Cargar una imágen en vez",
"id.verification.photo.camera.help.title": "Usar la cámara en vez",
"id.verification.photo.upload.help.text": "Si estás presentando problemas usando el capturador de fotos, es posible que prefieras subir una foto en vez. Para subir una foto, has clic en el botón a continuación ",
"id.verification.photo.camera.help.text": "Si estás presentando problemas subiendo una foto, es posible que prefieras usar la cámara en vez. Para usar una cámara, has clic en el botón a continuación.",
"id.verification.upload.help.button": "Cambia a modo de carga",
"id.verification.camera.help.button": "Cambia a modo de cámara",
"id.verification.photo.upload.help.title": "Upload a Photo Instead",
"id.verification.photo.camera.help.title": "Use Your Camera Instead",
"id.verification.photo.upload.help.text": "If you are having trouble using the photo capture above, you may want to upload a photo instead. To upload a photo, click the button below.",
"id.verification.photo.camera.help.text": "If you are having trouble uploading a photo above, you may want to use your camera instead. To use your camera, click the button below.",
"id.verification.upload.help.button": "Switch to Upload Mode",
"id.verification.camera.help.button": "Switch to Camera Mode",
"id.verification.choose.mode.title": "Photo Requirements Options",
"id.verification.choose.mode.hep.text": "To complete verification, please select one of the following options to submit photos. You will be able to switch between these options throughout the process if needed.",
"id.verification.choose.mode.radio.upload": "Upload photos from my device",
"id.verification.choose.mode.radio.camera": "Take pictures using my camera",
"id.verification.account.name.managed.alert": "Your profile settings are managed by {managerTitle}, so you are not allowed to update your name. Please contact your {profileDataManager} administrator or {support} for help.",
"id.verification.request.camera.access.instructions": "Para tomar una foto con tu cámara web, es posible que recibas un aviso del navegador para acceder a tu cámara. {clickAllow}",
"id.verification.requirements.account.managed.alert": "La configuración de tu perfil es administrada por {managerTitle}. Por lo tanto si el nombre en tu ID de foto coincide con tu nombre de cuenta por favor ponte en contacto con tu administrador de {profileDataManager} o con {soporte} para solicitar ayuda antes de completar el proceso de verificación de foto.",
"id.verification.requirements.account.managed.alert": "Your account settings are managed by {managerTitle}. If the name on your photo ID does not match the name on your account, please contact your {profileDataManager} administrator or {support} for help before completing the Photo Verification process.",
"id.verification.requirements.card.device.text": "Necesitas un dispositivo que tenga una cámara. Si has recibido un aviso del navegador para habilitar acceso a tu cámara, por favor asegúrate de seleccionar [allow].",
"id.verification.account.name.summary.alert": "La configuración de tu perfil es administrada por {managerTitle}. Por lo tanto si el nombre en tu ID de foto coincide con tu nombre de cuenta por favor ponte en contacto con tu administrador de {profileDataManager} o con {soporte} para solicitar ayuda.",
"id.verification.account.name.summary.alert": "Your account settings are managed by {managerTitle}. If the name on your photo ID does not match the name on your account, please contact your {profileDataManager} administrator or {support} for help.",
"idv.submission.alert.error": "\n Se produjo un error técnico al intentar enviar la verificación de ID.\n Es posible que sea una cuestión temporal, así que inténtalo de nuevo en unos minutos.\n Si el problema continúa, dirígete a {support_link} para obtener ayuda.\n ",
"id.verification.account.name.edit": "Editar {sr}"
"id.verification.account.name.edit": "Edit {sr}"
}

View File

@@ -1,350 +1,318 @@
{
"account.settings.message.duplicate.tpa.provider": "Le compte {provider} que vous avez sélectionné est déjà lié à un autre compte {siteName}.",
"account.settings.message.managed.settings": "Vos paramètres de profile sont gérés par {managerTitle}. Contactez votre administrateur ou {support} en cas de besoin.",
"account.settings.message.duplicate.tpa.provider": "The {provider} account you selected is already linked to another edX account.",
"account.settings.message.managed.settings": "Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help.",
"account.settings.message.managed.settings.support": "support",
"account.settings.page.heading": "Paramètres du compte",
"account.settings.loading.message": "Chargement...",
"account.settings.loading.error": "Erreur : {error}",
"account.settings.banner.beta.language": "Vous avez réglé votre langue sur {beta_language}, qui n'est actuellement pas entièrement traduite. Vous pouvez nous aider à traduire complètement cette langue en rejoignant la communauté Transifex et en ajoutant des traductions de l'anglais pour les apprenants qui parlent {beta_language}.",
"account.settings.banner.beta.language.action.switch.back": "Revenir au {previous_language}",
"account.settings.banner.beta.language.action.help.translate": "Aidez-nous à traduire en {beta_language}",
"account.settings.section.account.information": "Information du compte",
"account.settings.section.account.information.description": "Ces paramètres comprennent des informations de base à propos de votre compte.",
"account.settings.section.profile.information": "Informations de profil",
"account.settings.section.demographics.information": "Informations optionnelles",
"account.settings.section.site.preferences": "Préférences de site",
"account.settings.section.linked.accounts": "Comptes liés",
"account.settings.section.linked.accounts.description": "Vous pouvez associer vos comptes d'identité pour simplifier la connexion à {siteName}.",
"account.settings.field.username": "Nom dutilisateur",
"account.settings.field.username.help.text": "Le nom qui vous identifie sur {siteName}. Vous ne pouvez pas changer votre nom d'utilisateur.",
"account.settings.field.full.name": "Nom complet",
"account.settings.field.full.name.empty": "Ajouter un nom",
"account.settings.field.full.name.help.text": "Le nom qui apparaîtra sur vos certificats et dans le cadre de toute vérification d'identité.",
"account.settings.field.full.name.help.text.default": "Le nom qui apparaît sur votre profile public.",
"account.settings.field.full.name.help.text.default.certificate": "Ce nom va apparaître sur vos attestations et dossiers publics.",
"account.settings.field.name.verified": "Nom vérifié",
"account.settings.field.name.verified.help.text.verified": "Ce nom a été vérifié par une pièce d&#39;identité avec photo.",
"account.settings.field.name.verified.help.text.verified.proctored": "Ce nom a été vérifié par la surveillance.",
"account.settings.field.name.verified.help.text.verified.certificate": "Ce nom a été vérifié par une pièce d&#39;identité avec photo et est sélectionné pour apparaître sur vos certificats et vos dossiers publics.",
"account.settings.field.name.verified.help.text.verified.proctored.certificate": "Ce nom a été vérifié par la surveillance et est sélectionné pour apparaître sur vos certificats et vos enregistrements publics.",
"account.settings.field.name.verified.help.text.submitted": "La vérification a été soumise. Cela prend généralement 48 heures ou moins. Le nom vérifié ne peut pas être modifié pour le moment.",
"account.settings.field.name.verified.help.text.submitted.proctored": "Votre examen surveillé a été soumis. Le nom vérifié ne peut pas être modifié pour le moment. Veuillez réessayer dans 2 à 5 jours.",
"account.settings.field.name.verified.help.text.submitted.certificate": "Une fois la vérification d'identité réussie, ce nom apparaîtra sur vos attestations et vos dossiers publics. Le nom complet ne peut pas être modifié pour le moment.",
"account.settings.field.name.verified.help.text.submitted.proctored.certificate": "Une fois que votre examen surveillé a réussi l&#39;examen, ce nom apparaîtra sur votre certificat et vos dossiers publics. Le nom vérifié ne peut pas être modifié pour le moment.",
"account.settings.field.name.verified.verification.help": "Entrez votre nom tel qu&#39;il apparaît sur votre carte d&#39;étudiant, de travail ou d&#39;identité émise par le gouvernement.",
"account.settings.field.full.name.help.text.submitted": "La vérification a été soumise. Cela prend généralement 48 heures ou moins. Le nom vérifié ne peut pas être modifié pour le moment.",
"account.settings.field.full.name.help.text.submitted.proctored": "Votre examen surveillé a été soumis. Le nom complet ne peut pas être modifié pour le moment. Veuillez réessayer dans 2 à 5 jours.",
"account.settings.field.full.name.help.text.submitted.certificate": "Une fois la vérification d'identité réussie, ce nom apparaîtra sur vos attestations et vos dossiers publics. Le nom complet ne peut pas être modifié pour le moment.",
"account.settings.field.full.name.help.text.submitted.proctored.certificate": "Une fois que votre examen surveillé a réussi l&#39;examen, ce nom apparaîtra sur vos certificats et vos dossiers publics. Le nom complet ne peut pas être modifié pour le moment.",
"account.settings.field.name.verified.success.message": "Votre demande de vérification d'identité a été complétée avec succès. Vous avez maintenant la possibilité de sélectionner le nom que vous préférez voir apparaître sur vos attestations et documents publics.",
"account.settings.field.name.verified.success.message.header": "Votre demande de changement de nom est terminée!",
"account.settings.field.name.verified.failure.message": "Votre dernière tentative de vérification d'identité n'a pas abouti. Les paramètres du compte correspondant ont été restaurés.",
"account.settings.field.name.verified.failure.message.header": "Nous n'avons pas pu vérifier votre identité.",
"account.settings.field.name.verified.failure.message.help.link": "En savoir plus sur la vérification d'identité",
"account.settings.field.name.verified.submitted.message": "Votre demande de vérification d'identité a bien été envoyée et prend généralement entre 24 et 48 heures afin d'être validée.",
"account.settings.field.name.verified.submitted.message.certificate": "Lorsque votre demande est approuvée, votre nouveau nom apparaîtra sur tous les certificats associés et les enregistrements publics.",
"account.settings.field.name.verified.submitted.message.header": "Votre demande de changement de nom est presque terminée!",
"account.settings.field.email": "Adresse e-mail (Connexion)",
"account.settings.field.email.empty": "Ajouter l'adresse e-mail",
"account.settings.field.email.confirmation": "Un message de confirmation a été envoyé à {value}. Cliquer le lien dans le message afin de mettre à jour votre adresse email.",
"account.settings.field.email.help.text": "Vous recevrez des messages de {siteName} et des équipes pédagogiques à cette adresse.",
"account.settings.field.secondary.email": "Adresse électronique de récupération",
"account.settings.field.secondary.email.empty": "Ajouter une adresse électronique de récupération",
"account.settings.field.secondary.email.confirmation": "Un message de confirmation a été envoyé à {value}. Cliquer le lien dans le message afin de mettre à jour votre adresse email.",
"account.settings.email.field.confirmation.header": "En attente de confirmation",
"account.settings.field.dob": "Année de naissance",
"account.settings.field.dob.empty": "Ajouter année de naissance",
"account.settings.field.year_of_birth.options.empty": "Sélectionnez une année de naissance",
"account.settings.field.country": "Pays",
"account.settings.field.country.empty": "Ajouter un pays",
"account.settings.field.country.options.empty": "Sélectionnez un pays",
"account.settings.field.state": "État",
"account.settings.field.state.empty": "Ajouter un état",
"account.settings.field.state.options.empty": "Choisir un état",
"account.settings.field.site.language": "Langue du site",
"account.settings.field.site.language.help.text": "La langue utilisée au travers du site. Le site est actuellement disponible dans un nombre limité de langues.",
"account.settings.page.heading": "Account Settings",
"account.settings.loading.message": "Loading...",
"account.settings.loading.error": "Error: {error}",
"account.settings.banner.beta.language": "You have set your language to {beta_language}, which is currently not fully translated. You can help us translate this language fully by joining the Transifex community and adding translations from English for learners that speak {beta_language}.",
"account.settings.banner.beta.language.action.switch.back": "Switch Back to {previous_language}",
"account.settings.banner.beta.language.action.help.translate": "Help Translate into {beta_language}",
"account.settings.section.account.information": "Account Information",
"account.settings.section.account.information.description": "These settings include basic information about your account.",
"account.settings.section.profile.information": "Profile Information",
"account.settings.section.demographics.information": "Optional Information",
"account.settings.section.site.preferences": "Site Preferences",
"account.settings.section.linked.accounts": "Linked Accounts",
"account.settings.section.linked.accounts.description": "You can link your identity accounts to simplify signing in to edX.",
"account.settings.field.username": "Username",
"account.settings.field.username.help.text": "The name that identifies you on edX. You cannot change your username.",
"account.settings.field.full.name": "Full name",
"account.settings.field.full.name.empty": "Add name",
"account.settings.field.full.name.help.text": "The name that is used for ID verification and that appears on your certificates.",
"account.settings.field.email": "Email address (Sign in)",
"account.settings.field.email.empty": "Add email address",
"account.settings.field.email.confirmation": "Weve sent a confirmation message to {value}. Click the link in the message to update your email address.",
"account.settings.field.email.help.text": "You receive messages from edX and course teams at this address.",
"account.settings.field.secondary.email": "Recovery email address",
"account.settings.field.secondary.email.empty": "Add a recovery email address",
"account.settings.field.secondary.email.confirmation": "Weve sent a confirmation message to {value}. Click the link in the message to update your recovery email address.",
"account.settings.email.field.confirmation.header": "Pending confirmation",
"account.settings.field.dob": "Year of birth",
"account.settings.field.dob.empty": "Add year of birth",
"account.settings.field.year_of_birth.options.empty": "Select a year of birth",
"account.settings.field.country": "Country",
"account.settings.field.country.empty": "Add country",
"account.settings.field.country.options.empty": "Select a Country",
"account.settings.field.state": "State",
"account.settings.field.state.empty": "Add state",
"account.settings.field.state.options.empty": "Select a State",
"account.settings.field.site.language": "Site language",
"account.settings.field.site.language.help.text": "The language used throughout this site. This site is currently available in a limited number of languages.",
"account.settings.field.education": "Education",
"account.settings.field.education.empty": "Ajouter le niveau d'éducation",
"account.settings.field.education.levels.empty": "Sélectionnez un niveau d'éducation",
"account.settings.field.education.levels.p": "Doctorat",
"account.settings.field.education.levels.m": "Master ou diplôme professionnel",
"account.settings.field.education.levels.b": "Diplôme de licence",
"account.settings.field.education.levels.a": "Grade de l'associé",
"account.settings.field.education.levels.hs": "Lycée / enseignement secondaire",
"account.settings.field.education.levels.jhs": "Collège / enseignement secondaire inférieur",
"account.settings.field.education.levels.el": "Enseignement primaire",
"account.settings.field.education.levels.none": "Sans diplôme",
"account.settings.field.education.levels.o": "Autre niveau d'étude",
"account.settings.field.gender": "Genre",
"account.settings.field.gender.empty": "Ajouter le genre",
"account.settings.field.gender.options.empty": "Sélectionner un genre",
"account.settings.field.gender.options.f": "Femme",
"account.settings.field.gender.options.m": "Homme",
"account.settings.field.gender.options.o": "Autre",
"account.settings.field.language.proficiencies": "Langue parlée",
"account.settings.field.language.proficiencies.empty": "Ajouter une langue parlée",
"account.settings.field.language_proficiencies.options.empty": "Sélectionnez une langue",
"account.settings.field.time.zone": "Fuseau horaire",
"account.settings.field.time.zone.empty": "Définir le fuseau horaire",
"account.settings.field.time.zone.description": "Sélectionnez le fuseau horaire pour l'affichage des dates de cours. Si vous n'indiquez aucun fuseau horaire, les dates de cours, y compris les échéances de devoirs, seront affichés en fonction du fuseau horaire local de votre navigateur. ",
"account.settings.field.time.zone.default": "Défaut (fuseau horaire local)",
"account.settings.field.time.zone.all": "Tous les fuseaux horaires",
"account.settings.field.time.zone.country": "Fuseaux horaires des pays",
"account.settings.section.social.media": "Liens vers les réseaux sociaux",
"account.settings.section.social.media.description": "Optionnellement, liez vos comptes personnels aux icônes des médias sociaux sur votre profil {siteName}.",
"account.settings.field.education.empty": "Add level of education",
"account.settings.field.education.levels.empty": "Select a level of education",
"account.settings.field.education.levels.p": "Doctorate",
"account.settings.field.education.levels.m": "Master's or professional degree",
"account.settings.field.education.levels.b": "Bachelor's Degree",
"account.settings.field.education.levels.a": "Associate's degree",
"account.settings.field.education.levels.hs": "Secondary/high school",
"account.settings.field.education.levels.jhs": "Junior secondary/junior high/middle school",
"account.settings.field.education.levels.el": "Elementary/primary school",
"account.settings.field.education.levels.none": "No formal education",
"account.settings.field.education.levels.o": "Other education",
"account.settings.field.gender": "Gender",
"account.settings.field.gender.empty": "Add gender",
"account.settings.field.gender.options.empty": "Select a gender",
"account.settings.field.gender.options.f": "Female",
"account.settings.field.gender.options.m": "Male",
"account.settings.field.gender.options.o": "Other",
"account.settings.field.language.proficiencies": "Spoken language",
"account.settings.field.language.proficiencies.empty": "Add a spoken language",
"account.settings.field.language_proficiencies.options.empty": "Select a Language",
"account.settings.field.time.zone": "Time zone",
"account.settings.field.time.zone.empty": "Set time zone",
"account.settings.field.time.zone.description": "Select the time zone for displaying course dates. If you do not specify a time zone, course dates, including assignment deadlines, will be displayed in your browsers local time zone.",
"account.settings.field.time.zone.default": "Default (Local Time Zone)",
"account.settings.field.time.zone.all": "All time zones",
"account.settings.field.time.zone.country": "Country time zones",
"account.settings.section.social.media": "Social Media Links",
"account.settings.section.social.media.description": "Optionally, link your personal accounts to the social media icons on your edX profile.",
"account.settings.field.social.platform.name.linkedin": "LinkedIn",
"account.settings.field.social.platform.name.linkedin.empty": "Ajouter un profil LinkedIn",
"account.settings.jump.nav.delete.account": "Supprimer mon compte",
"account.settings.field.social.platform.name.linkedin.empty": "Add LinkedIn profile",
"account.settings.jump.nav.delete.account": "Delete My Account",
"account.settings.field.social.platform.name.twitter": "Twitter",
"account.settings.field.social.platform.name.twitter.empty": "Ajouter un profil Twitter",
"account.settings.field.social.platform.name.twitter.empty": "Add Twitter profile",
"account.settings.field.social.platform.name.facebook": "Facebook",
"account.settings.field.social.platform.name.facebook.empty": "Ajouter un profil Facebook",
"account.settings.editable.field.action.save": "Enregistrer",
"account.settings.editable.field.action.cancel": "Annuler",
"account.settings.editable.field.action.edit": "Modifier",
"account.settings.static.field.empty": "Aucune valeur n'a été fixée. Contactez l'administrateur de votre {entreprise} pour effectuer des modifications.",
"account.settings.static.field.empty.no.admin": "Pas de valeur définie.",
"account.settings.field.name.certificate.select": "Si sélectionné, ce nom va apparaître sur vos attestations et dossiers publics.",
"account.settings.field.name.modal.certificate.title": "Choisissez le nom qui va apparaîtres sur vos attestations et dossiers publics.",
"account.settings.field.name.modal.certificate.select": "Sélectionnez un nom",
"account.settings.field.name.modal.certificate.option.full": "Nom complet",
"account.settings.field.name.modal.certificate.option.verified": "Nom vérifié",
"account.settings.field.name.modal.certificate.button.choose": "Choisissez un nom",
"account.settings.coaching.consent.welcome.header": "Commençons.",
"account.settings.coaching.consent.welcome.subheader": "Nous sommes là pour vous du début à la fin",
"account.settings.coaching.consent.description": "Les programmes MicroBachelors comprennent un coaching axé sur votre carrière, vos études et la façon dont vous obtiendrez des résultats grâce à une communication individuelle avec un professionnel expérimenté. Si vous êtes intéressé, fournissez les informations ci-dessous et cliquez sur \"Soumettre\". Notre partenaire de coaching vous contactera par courrier électronique et/ou par SMS pour vous aider à progresser. Les conditions générales s'appliquent.*",
"account.settings.coaching.consent.text-messaging.disclaimer": "* Les services de coaching sont inclus sans frais supplémentaires pour les apprenants ayant un numéro de téléphone aux États-Unis. Le coaching comprend des messages textes récurrents. Les tarifs des messages et des données peuvent s'appliquer. Text STOP pour se désengager.",
"account.settings.coaching.consent.accept-coaching": "S'inscrire pour le coaching.",
"account.settings.coaching.consent.decline-coaching": "Je préfère ne pas être contacté par les services de coaching gratuit.",
"account.settings.coaching.consent.label.name": "Veuillez confirmer votre nom",
"account.settings.coaching.consent.label.phone-number": "Entrer un numéro de cellulaire",
"account.settings.coaching.consent.success.header": "Opération réussie!",
"account.settings.coaching.consent.success.message": "Vous êtes inscrit au coaching. Vous pouvez vous attendre à un message par courriel ou SMS dans les prochains jours.",
"account.settings.coaching.consent.success.continue": "Démarrer mon cours",
"account.settings.field.social.platform.name.facebook.empty": "Add Facebook profile",
"account.settings.editable.field.action.save": "Save",
"account.settings.editable.field.action.cancel": "Cancel",
"account.settings.editable.field.action.edit": "Edit",
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
"account.settings.static.field.empty.no.admin": "No value set.",
"account.settings.coaching.consent.welcome.header": "Lets get started.",
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
"account.settings.coaching.consent.label.name": "Please confirm your name",
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
"account.settings.coaching.consent.success.header": "Success!",
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You can expect a message via email or SMS in the coming days.",
"account.settings.coaching.consent.success.continue": "Start my course",
"account.settings.coaching.managed.support": "support",
"account.settings.coaching.managed.alert": "Votre nom est géré par {managerTitle}. Contactez votre administrateur pour obtenir de l'aide.",
"account.settings.field.phone_number": "Numéro de téléphone",
"account.settings.field.phone_number.empty": "Ajouter un numéro de téléphone",
"account.settings.field.coaching_consent": "Consentement pour le coaching",
"account.settings.field.coaching_consent.tooltip": "Un parcours MicroBachelors inclus du coaching par texto avec un professionnel expériementé qui vous aidera à combiner votre expérience éducationnelle et vos objectifs de carrière. Les services de coaching sont inclus sans coût additionnel aux apprenant ayant un numéro de téléphone US. Le coût standard des textos s'applique. Texter 'STOP' en tout temp pour arrêter les messages.",
"account.settings.field.coaching_consent.error": "Un numéro de téléphone US valide est requis pour s'inscrire pour du coaching",
"account.settings.delete.account.before.proceeding": "Avant de poursuivre, veuillez {actionLink}.",
"account.settings.delete.account.header": "Supprimer mon compte",
"account.settings.delete.account.subheader": "Nous sommes désolés de vous voir quitter!",
"account.settings.delete.account.text.1": "Veuillez noter que la suppression de votre compte et de vos données personnelles est permanente et ne peut être annulée. {siteName} ne pourra pas récupérer votre compte ou les données supprimées.",
"account.settings.delete.account.text.2": "Une fois votre compte supprimé, vous ne pourrez plus l'utiliser pour suivre des cours sur {siteName}.",
"account.settings.delete.account.text.2.edX": "Une fois votre compte supprimé, vous ne pourrez plus l'utiliser pour prendre des cours sur l'application edX, edx.org, ou tout autre site hébergé par edX. Cela comprend l'accès à edx.org à partir du système de votre employeur ou de votre université et l'accès aux sites privés offerts par MIT Open Learning, Wharton Executive Education, et Harvard Medical School.",
"account.settings.delete.account.text.3.link": "Suivez ces instructions pour imprimer ou télécharger une attestation",
"account.settings.delete.account.text.warning": "Avertissement : la suppression d'un compte est permanente. Veuillez lire attentivement ce qui précède avant de continuer. Cette action est irréversible et vous ne pourrez plus utiliser la même adresse courriel sur {siteName}.",
"account.settings.delete.account.text.change.instead": "Vous souhaitez modifier votre adresse électronique, votre nom ou votre mot de passe?",
"account.settings.delete.account.button": "Supprimer mon compte",
"account.settings.delete.account.please.activate": "activez votre compte",
"account.settings.delete.account.please.confirm": "confirmer votre compte",
"account.settings.delete.account.please.unlink": "dissocier tous les comptes de médias sociaux",
"account.settings.delete.account.modal.header": "Êtes-vous certain ?",
"account.settings.delete.account.modal.text.1": "Vous avez sélectionné \"Supprimer mon compte\". La suppression de votre compte et de vos données personnelles est permanente et ne peut être annulée. {siteName} ne pourra ni récupérer votre compte ni les données supprimées.",
"account.settings.delete.account.modal.text.2": "Si vous continuez, vous ne pourrez plus utiliser ce compte pour suivre des cours sur {siteName}.",
"account.settings.delete.account.modal.text.2.edX": "Si vous procédez ainsi, vous ne pourrez pas utiliser ce compte pour suivre des cours sur l'application edX, edx.org ou tout autre site hébergé par edX. Cela inclut l'accès à edx.org depuis le système de votre employeur ou de votre université et l'accès aux sites privés proposés par MIT Open Learning, Wharton Executive Education et Harvard Medical School.",
"account.settings.delete.account.modal.enter.password": "Si vous souhaitez toujours supprimer votre compte, veuillez saisir le mot de passe de votre compte :",
"account.settings.delete.account.modal.confirm.delete": "Oui, supprimer",
"account.settings.delete.account.modal.confirm.cancel": "Annuler",
"account.settings.delete.account.error.unable.to.delete": "Impossible de supprimer le compte",
"account.settings.delete.account.error.no.password": "Un mot de passe est requis",
"account.settings.delete.account.error.invalid.password": "Mot de passe incorrect.",
"account.settings.delete.account.error.unable.to.delete.details": "Nous sommes désolés, il y a eu une erreur dans le suivi de votre demande. Veuillez réessayer plus tard.",
"account.settings.delete.account.modal.after.header": "Nous sommes désolés de vous voir quitter! Votre compte sera bientôt supprimé.",
"account.settings.delete.account.modal.after.text": "La suppression d'un compte, comprenant le retrait des listes d'e-mail, peu prendre quelques semaines afin de traiter entièrement notre système. Si vous souhaitez renoncer aux e-mails ultérieurement, veuillez vous désinscrire en consultant le pied de page de n'importe quel e-mail.",
"account.settings.delete.account.modal.after.button": "Fermer",
"account.settings.delete.account.text.3.edX": "Vous pouvez également perdre l'accès aux attestations vérifiées et à d'autres informations d'identification de programme, comme les attestations MicroMasters. Vous pouvez en faire une copie pour vos dossiers avant de procéder à la suppression. {actionLink}.",
"account.settings.delete.account.text.3": "Vous pouvez également perdre accès aux attestations vérifiées et à d'autres informations d'identification de programme. Vous pouvez en faire une copie pour vos dossiers avant de procéder à la suppression.",
"account.settings.message.demographics.service.issue": "Une erreur s'est produite lors de la tentative de récupération ou d'enregistrement des informations de votre compte. Veuillez réessayer plus tard.",
"account.settings.field.demographics.gender": "Identité de genre",
"account.settings.field.demographics.gender.empty": "Ajouter une identité de genre",
"account.settings.field.demographics.gender.options.empty": "Sélectionnez une identité de genre",
"account.settings.field.demographics.gender_description": "Description de l'identité de genre",
"account.settings.field.demographics.gender_description.empty": "Entrez la description",
"account.settings.field.demographics.ethnicity": "Identité raciale / ethnique",
"account.settings.field.demographics.ethnicity.empty": "Ajouter une identité raciale / ethnique",
"account.settings.field.demographics.ethnicity.options.empty": "Sélectionnez tout ce qui s'y rapporte",
"account.settings.field.demographics.income": "Revenu familial",
"account.settings.field.demographics.income.empty": "Ajouter le revenu familial",
"account.settings.field.demographics.income.options.empty": "Sélectionnez une fourchette de revenu familial",
"account.settings.field.demographics.military_history": "Statut militaire U.S.",
"account.settings.field.demographics.military_history.empty": "Ajouter un statut militaire",
"account.settings.field.demographics.military_history.options.empty": "Sélectionnez un statut militaire",
"account.settings.field.demographics.learner_education_level": "Votre niveau d'éducation",
"account.settings.field.demographics.learner_education_level.empty": "Ajouter un niveau d'éducation",
"account.settings.field.demographics.parent_education_level": "Niveau d'éducation des parents / tuteurs",
"account.settings.field.demographics.parent_education_level.empty": "Ajouter un niveau d'éducation",
"account.settings.field.demographics.education_level.options.empty": "Sélectionnez le niveau d'éducation",
"account.settings.field.demographics.work_status": "Statut d'emploi",
"account.settings.field.demographics.work_status.empty": "Ajouter un statut d'emploi",
"account.settings.field.demographics.work_status.options.empty": "Sélectionnez le statut d'emploi",
"account.settings.field.demographics.work_status_description": "Description du statut d'emploi",
"account.settings.field.demographics.work_status_description.empty": "Entrez la description",
"account.settings.field.demographics.current_work_sector": "Secteur d'emploi actuel",
"account.settings.field.demographics.current_work_sector.empty": "Ajouter le secteur d'emploi",
"account.settings.field.demographics.future_work_sector": "Secteur d'emploi futur",
"account.settings.field.demographics.future_work_sector.empty": "Ajouter le secteur d'emploi",
"account.settings.field.demographics.work_sector.options.empty": "Sélectionnez un secteur d'emploi",
"account.settings.section.demographics.why": "Pourquoi est-ce que {siteName} collecte ces informations ?",
"account.settings.name.change.title.id": "Ce changement de nom requiert une vérification d'identité.",
"account.settings.name.change.title.begin": "Avant que l'on commençons",
"account.settings.name.change.warning.one": "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.",
"account.settings.name.change.warning.two": "Cette action ne peut pas être renversée sans vérification d'identité.",
"account.settings.name.change.id.name.label": "Entrez votre nom tel qu&#39;il apparaît sur votre carte d&#39;étudiant, de travail ou d&#39;identité émise par le gouvernement.",
"account.settings.name.change.id.name.placeholder": "Entrez le nom sur votre pièce d&#39;identité avec photo",
"account.settings.name.change.error.valid.name": "Entrez un nom valide",
"account.settings.name.change.error.general": "Une erreur est survenue. Veuillez réessayer.",
"account.settings.name.change.continue": "Continuer",
"account.settings.name.change.cancel": "Annuler",
"error.notfound.message": "La page que vous recherchez n'est pas disponible ou il y a une erreur dans l'URL. Veuillez vérifier l'URL et réessayer.",
"account.settings.coaching.managed.alert": "Your name is managed by {managerTitle}. Contact your administrator for help.",
"account.settings.field.phone_number": "Phone Number",
"account.settings.field.phone_number.empty": "Add a phone number",
"account.settings.field.coaching_consent": "Coaching consent",
"account.settings.field.coaching_consent.tooltip": "MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available to learners with U.S. mobile phone numbers. Standard messaging rates apply. Text STOP at anytime to opt-out of messages.",
"account.settings.field.coaching_consent.error": "A valid US phone number is required to opt into coaching",
"account.settings.delete.account.before.proceeding": "Before proceeding, please {actionLink}.",
"account.settings.delete.account.header": "Delete My Account",
"account.settings.delete.account.subheader": "We're sorry to see you go!",
"account.settings.delete.account.text.1": "Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.text.2": "Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.text.3.link": "follow the instructions for printing or downloading a certificate",
"account.settings.delete.account.text.warning": "Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.",
"account.settings.delete.account.text.change.instead": "Want to change your email, name, or password instead?",
"account.settings.delete.account.button": "Delete My Account",
"account.settings.delete.account.please.activate": "activate your account",
"account.settings.delete.account.please.unlink": "unlink all social media accounts",
"account.settings.delete.account.modal.header": "Are you sure?",
"account.settings.delete.account.modal.text.1": "You have selected \"Delete My Account\". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.modal.text.2": "If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.modal.enter.password": "If you still wish to continue and delete your account, please enter your account password:",
"account.settings.delete.account.modal.confirm.delete": "Yes, Delete",
"account.settings.delete.account.modal.confirm.cancel": "Cancel",
"account.settings.delete.account.error.unable.to.delete": "Unable to delete account",
"account.settings.delete.account.error.no.password": "A password is required",
"account.settings.delete.account.error.invalid.password": "Password is incorrect",
"account.settings.delete.account.error.unable.to.delete.details": "Sorry, there was an error trying to process your request. Please try again later.",
"account.settings.delete.account.modal.after.header": "We're sorry to see you go! Your account will be deleted shortly.",
"account.settings.delete.account.modal.after.text": "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.",
"account.settings.delete.account.modal.after.button": "Close",
"account.settings.delete.account.text.3": "You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion, {actionLink}.",
"account.settings.message.demographics.service.issue": "An error occurred attempting to retrieve or save your account information. Please try again later.",
"account.settings.field.demographics.gender": "Gender identity",
"account.settings.field.demographics.gender.empty": "Add gender identity",
"account.settings.field.demographics.gender.options.empty": "Select a gender identity",
"account.settings.field.demographics.gender_description": "Gender identity description",
"account.settings.field.demographics.gender_description.empty": "Enter description",
"account.settings.field.demographics.ethnicity": "Race/Ethnicity identity",
"account.settings.field.demographics.ethnicity.empty": "Add race/ethnicity identity",
"account.settings.field.demographics.ethnicity.options.empty": "Select all that apply",
"account.settings.field.demographics.income": "Family income",
"account.settings.field.demographics.income.empty": "Add family income",
"account.settings.field.demographics.income.options.empty": "Select a family income range",
"account.settings.field.demographics.military_history": "U.S. Military status",
"account.settings.field.demographics.military_history.empty": "Add military status",
"account.settings.field.demographics.military_history.options.empty": "Select military status",
"account.settings.field.demographics.learner_education_level": "Your education level",
"account.settings.field.demographics.learner_education_level.empty": "Add education level",
"account.settings.field.demographics.parent_education_level": "Parents/Guardians education level",
"account.settings.field.demographics.parent_education_level.empty": "Add education level",
"account.settings.field.demographics.education_level.options.empty": "Select education level",
"account.settings.field.demographics.work_status": "Employment status",
"account.settings.field.demographics.work_status.empty": "Add employment status",
"account.settings.field.demographics.work_status.options.empty": "Select employment status",
"account.settings.field.demographics.work_status_description": "Employment status description",
"account.settings.field.demographics.work_status_description.empty": "Enter description",
"account.settings.field.demographics.current_work_sector": "Current work industry",
"account.settings.field.demographics.current_work_sector.empty": "Add work industry",
"account.settings.field.demographics.future_work_sector": "Future work industry",
"account.settings.field.demographics.future_work_sector.empty": "Add work industry",
"account.settings.field.demographics.work_sector.options.empty": "Select work industry",
"account.settings.section.demographics.why": "Why does edX collect this information?",
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"account.settings.editable.field.password.reset.button.confirmation.support.link": "technical support",
"account.settings.editable.field.password.reset.button.confirmation": "Nous avons envoyé un message à {email}. Cliquez sur le lien dans le message pour réinitialiser votre mot de passe. Vous n'avez pas reçu le message? Contactez {technicalSupportLink}.",
"account.settings.editable.field.password.reset.button": "Réinitialiser le mot de passe",
"account.settings.editable.field.password.reset.button.forbidden": "Votre demande précédente est en cours, veuillez réessayer dans quelques instants.",
"account.settings.editable.field.password.reset.label": "Mot de passe",
"account.settings.sso.link.account": "Se connecter avec {name}",
"account.settings.sso.account.connected": "Lié",
"account.settings.sso.account.disconnect.error": "Un problème est survenu lors de la déconnexion de ce compte. Contactez le support si le problème persiste.",
"account.settings.sso.unlink.account": "Dissocier le compte {name}",
"account.settings.sso.no.providers": "Aucun compte ne peut être lié pour le moment.",
"id.verification.access.blocked.denied": "Nous ne pouvez pas vérifier votre identité pour le moment. Si vous n'avez pas encore activé votre compte, veuillez vérifier votre dossier de pourriels pour le courriel d'activation de {email}.",
"id.verification.next": "Suivant",
"account.settings.editable.field.password.reset.button.confirmation": "We've sent a message to {email}. Click the link in the message to reset your password. Didn't receive the message? Contact {technicalSupportLink}.",
"account.settings.editable.field.password.reset.button": "Reset Password",
"account.settings.editable.field.password.reset.button.forbidden": "Your previous request is in progress, please try again in few moments.",
"account.settings.editable.field.password.reset.label": "Password",
"account.settings.sso.link.account": "Sign in with {name}",
"account.settings.sso.account.connected": "Linked",
"account.settings.sso.account.disconnect.error": "There was a problem disconnecting this account. Contact support if the problem persists.",
"account.settings.sso.unlink.account": "Unlink {name} account",
"account.settings.sso.no.providers": "No accounts can be linked at this time.",
"id.verification.access.blocked.denied": "You cannot verify your identity at this time. If you have yet to activate your account, please check your spam folder for the activation email from {email}.",
"id.verification.next": "Next",
"id.verification.support": "support",
"id.verification.example.card.alt": "Exemple de carte d'identité valide avec un nom complet et une photo.",
"id.verification.requirements.title": "Exigences de vérification des photos",
"id.verification.requirements.description": "Afin de procéder à la vérification des photos, vous aurez besoin des éléments suivants :",
"id.verification.requirements.card.device.title": "Appareil avec caméra",
"id.verification.requirements.card.device.allow": "Autoriser",
"id.verification.requirements.card.id.title": "Carte d'identité avec photo",
"id.verification.requirements.card.id.text": "Vous avez besoin d'une carte d'identité valide contenant votre nom complet et votre photo, comme un permis de conduire ou un passeport.",
"id.verification.privacy.title": "Information sur la confidentialité",
"id.verification.privacy.need.photo.question": "Pourquoi est-ce que {siteName} a besoin de ma photo?",
"id.verification.privacy.need.photo.answer": "Nous utilisons vos photos de vérification pour confirmer votre identité et assurer la validité de votre certificat.",
"id.verification.privacy.do.with.photo.question": "Qu'est-ce que {siteName} fait avec cette photo?",
"id.verification.privacy.do.with.photo.answer": "Nous chiffrons votre photo de manière sécurisée et l'envoyons à notre service d'autorisation pour vérification. Votre photo et vos informations ne sont pas enregistrées ni visibles nulle part sur {siteName} une fois le processus de vérification terminé.",
"id.verification.access.blocked.title": "Vérification d'identité",
"id.verification.access.blocked.enrollment": "Vous n'êtes actuellement pas inscrit à un cours nécessitant une vérification d'identité.",
"id.verification.access.blocked.pending": "Vous avez déjà soumis vos informations de vérification. Vous verrez un message sur votre tableau de bord lorsque le processus de vérification sera terminé (généralement dans les 5 jours).",
"id.verification.photo.take": "Prendre une photo",
"id.verification.photo.retake": "Reprendre la photo ?",
"id.verification.photo.enable.detection": "Activer la détection des visages",
"id.verification.photo.enable.detection.portrait.help.text": "Si coché, une case apparaîtra autour de votre visage. Votre visage peut être vu clairement si la boîte qui l'entoure est bleue. Si votre visage n'est pas dans une bonne position ou indétectable, la case sera rouge.",
"id.verification.photo.enable.detection.id.help.text": "Si coché, une case apparaîtra autour du visage sur votre pièce d'identité. Le visage peut être vu clairement si la boîte qui l'entoure est bleue. Si le visage n'est pas dans une bonne position ou indétectable, la case sera rouge.",
"id.verification.photo.feedback.correct": "Le visage est en bonne position.",
"id.verification.photo.feedback.two.faces": "Plus d'un visage détecté.",
"id.verification.photo.feedback.no.faces": "Aucun visage détecté.",
"id.verification.photo.feedback.top.left": "Position incorrecte. En haut à gauche.",
"id.verification.photo.feedback.top.center": "Position incorrecte. En haut au centre.",
"id.verification.photo.feedback.top.right": "Position incorrecte. En haut à droite.",
"id.verification.photo.feedback.center.left": "Position incorrecte. Centre gauche.",
"id.verification.photo.feedback.center.center": "Position incorrecte. Trop près de la caméra.",
"id.verification.photo.feedback.center.right": "Position incorrecte. Centre droit.",
"id.verification.photo.feedback.bottom.left": "Position incorrecte. En bas à gauche.",
"id.verification.photo.feedback.bottom.center": "Position incorrecte. En bas au centre.",
"id.verification.photo.feedback.bottom.right": "Position incorrecte. En bas à droite.",
"id.verification.camera.access.title": "Autorisations de la caméra",
"id.verification.camera.access.title.success": "Accès à la caméra activé",
"id.verification.camera.access.title.failed": "L'accès à la caméra a échoué",
"id.verification.camera.access.click.allow": "Veuillez vous assurer de cliquer sur \"Autoriser\"",
"id.verification.camera.access.enable": "Activer la caméra",
"id.verification.camera.access.problems": "Vous avez des problèmes?",
"id.verification.camera.access.skip": "Ignorer et télécharger les fichiers image au lieu",
"id.verification.camera.access.success": "Il semble que votre appareil photo fonctionne et est prêt.",
"id.verification.camera.access.failure": "Il semble que nous ne puissions pas accéder à votre caméra. Vous devrez télécharger des fichiers image de vous et de votre pièce d'identité.",
"id.verification.camera.access.failure.temporary": "Il semblerait que nous ne pouvons pas accéder à votre caméra. Vérifiez qu'elle est bien connectée et que votre navigateur est autorisé à y accéder.",
"id.verification.camera.access.failure.temporary.chrome": "Pour activer l'accès à la caméra dans Chrome:",
"id.verification.camera.access.failure.temporary.chrome.step1": "Ouvrir Chrome.",
"id.verification.camera.access.failure.temporary.chrome.step2": "Accédez à Plus> Paramètres.",
"id.verification.camera.access.failure.temporary.chrome.step2.windows": "Pour Windows: Alt+F, Alt+E ou F10 suivi de la barre d'espace",
"id.verification.camera.access.failure.temporary.chrome.step2.mac": "Pour Mac: Command+,",
"id.verification.camera.access.failure.temporary.chrome.step3": "Sous l'onglet «Confidentialité et sécurité», sélectionnez «Paramètres du site», puis «Appareil photo».",
"id.verification.camera.access.failure.temporary.chrome.step4": "Sous \"Bloqué\", trouvez \"edx.org\" et sélectionnez-le.",
"id.verification.camera.access.failure.temporary.chrome.step5": "Dans la section \"Autorisations\", mettez à jour les autorisations de la caméra pour \"Autoriser\".",
"id.verification.camera.access.failure.temporary.ie11": "Pour activer l'accès à la caméra dans Internet Explorer:",
"id.verification.camera.access.failure.temporary.ie11.step1": "Ouvrez le Gestionnaire des paramètres de Flash Player en accédant à Paramètres Windows> Panneau de configuration> Flash Player.",
"id.verification.camera.access.failure.temporary.ie11.step2": "Sélectionnez l'onglet \"Caméra et micro\", puis sélectionnez le bouton \"Paramètres de la caméra et du microphone par site\".",
"id.verification.camera.access.failure.temporary.ie11.step3": "Choisissez \"edx.org\" dans la liste des sites Web et modifiez les autorisations en sélectionnant \"Autoriser\" dans le menu déroulant.",
"id.verification.camera.access.failure.temporary.firefox": "Pour activer l'accès à la caméra dans Firefox:",
"id.verification.camera.access.failure.temporary.firefox.step1": "Ouvrez Firefox.",
"id.verification.camera.access.failure.temporary.firefox.step2": "Saisissez \"about:preferences\" dans la barre d'URL.",
"id.verification.camera.access.failure.temporary.firefox.step3": "Sélectionnez l'onglet \"Confidentialité et sécurité\" et accédez à la section \"Autorisations\".",
"id.verification.camera.access.failure.temporary.firefox.step4": "À côté de \"Appareil photo\", sélectionnez le bouton \"Paramètres…\".",
"id.verification.camera.access.failure.temporary.firefox.step5": "Dans la barre de recherche, entrez \"edx.org\".",
"id.verification.camera.access.failure.temporary.firefox.step6": "Dans la colonne d'état de \"edx.org\", sélectionnez \"Autoriser\" dans la liste déroulante.",
"id.verification.camera.access.failure.temporary.firefox.step7": "Sélectionnez \"Enregistrer les modifications\".",
"id.verification.camera.access.failure.temporary.safari": "Pour activer l'accès à la caméra dans Safari:",
"id.verification.camera.access.failure.temporary.safari.step1": "Ouvrir Safari.",
"id.verification.camera.access.failure.temporary.safari.step2": "Cliquez sur le menu de l'application Safari, puis sélectionnez \"Préférences\". Vous pouvez également utiliser Command+ comme raccourci clavier.",
"id.verification.camera.access.failure.temporary.safari.step3": "Sélectionnez l'onglet \"Sites Web\", puis sélectionnez \"Appareil photo\".",
"id.verification.camera.access.failure.temporary.safari.step4": "Sélectionnez \"edx.org\" et changez les autorisations de la caméra en \"Allow\".",
"id.verification.camera.access.failure.unsupported": "Il semble que votre fureteur ne prend pas en charge l'accès à la caméra.",
"id.verification.camera.access.failure.unsupported.chrome.explanation": "Le fureteur Chrome ne prend actuellement pas en charge l'accès à la caméra sur les appareils iOS, tels que les iPhones et les iPad.",
"id.verification.camera.access.failure.unsupported.instructions": "Veuillez utiliser un autre navigateur pour terminer la vérification d'identité.",
"id.verification.photo.tips.title": "Conseils photo utiles",
"id.verification.photo.tips.description": "Ensuite, nous aurons besoin de vous preniez une photo de votre visage. Veuillez consulter les conseils utiles ci-dessous.",
"id.verification.photo.tips.list.title": "Conseils photo",
"id.verification.photo.tips.list.description": "Pour prendre une bonne photo, assurez-vous que: ",
"id.verification.photo.tips.list.well.lit": "Votre visage est bien éclairé",
"id.verification.photo.tips.list.inside.frame": " Votre visage est entièrement dans le cadre.",
"id.verification.portrait.photo.title.camera": "Prenez une photo de vous",
"id.verification.portrait.photo.instructions.camera": "Lorsque votre visage est en position, utilisez le bouton Prendre une photo ci-dessous pour prendre votre photo.",
"id.verification.camera.help.sight.question": "Que faire si je ne peux pas voir l'image de la caméra ou si je ne peux pas voir ma photo pour déterminer quel côté est visible?",
"id.verification.camera.help.sight.answer.portrait": "Vous pourrez peut-être terminer la procédure de capture d'image sans aide, mais cela peut prendre quelques tentatives de soumission pour obtenir le bon positionnement de la caméra. Le positionnement optimal de la caméra varie avec chaque ordinateur, mais généralement la meilleure position pour une prise de vue de la tête est d'environ 12-18 pouces (30-45 centimètres) de la caméra, la tête étant centrée par rapport à l'écran de l'ordinateur. Si les photos que vous soumettez sont rejetées, essayez de déplacer lorientation de lordinateur ou de lappareil photo pour modifier langle déclairage.",
"id.verification.camera.help.sight.answer.id": "Vous pourrez peut-être terminer la procédure de capture d'image sans aide, mais cela peut prendre quelques tentatives de soumission pour obtenir le bon positionnement de la caméra. Le positionnement optimal de la caméra varie avec chaque ordinateur, mais généralement, la meilleure position pour une photo d'une carte d'identité est de 8-12 pouces (20-30 centimètres) de la caméra, la carte d'identité étant centrée par rapport à la caméra. Si les photos que vous soumettez sont rejetées, essayez de déplacer lorientation de lordinateur ou de lappareil photo pour modifier langle déclairage. La raison la plus courante de rejet est l'incapacité de lire le texte sur la carte d'identité.",
"id.verification.camera.help.difficulty.question.portrait": "Que faire si j'ai des difficultés à maintenir ma tête en position par rapport à la caméra ?",
"id.verification.camera.help.difficulty.question.id": "Que faire si j'ai des difficultés à tenir ma carte d'identité en position par rapport à la caméra ?",
"id.verification.camera.help.difficulty.answer": "Si vous avez besoin d'aide pour prendre une photo à soumettre, contactez le support {siteName} pour des suggestions supplémentaires.",
"id.verification.id.photo.unclear.question": "L'image de votre carte d'identité n'est pas claire ou trop floue ?",
"id.verification.id.tips.title": "Conseils utiles pour les cartes d'identité",
"id.verification.id.tips.description": "Ensuite, nous vous demanderons de prendre une photo d'une carte d'identité valide comportant votre nom complet et votre photo, comme un permis de conduire ou un passeport. Veuillez préparer votre carte d'identité.",
"id.verification.id.tips.list.well.lit": "Votre carte d'identité est bien éclairée.",
"id.verification.id.tips.list.clear": "Assurez-vous que vous pouvez voir votre photo et lire clairement votre nom.",
"id.verification.id.photo.title.camera": "Prenez une photo de votre carte d'identité",
"id.verification.id.photo.title.upload": "Téléversez une photo de votre carte d'identité",
"id.verification.id.photo.preview.alt": "Aperçu de la pièce d'identité avec photo.",
"id.verification.id.photo.instructions.camera": "Lorsque votre pièce d'identité est en place, utilisez le bouton Prendre une photo ci-dessous pour prendre votre photo. Veuillez utiliser un passeport, un permis de conduire ou une autre carte d'identité comportant votre nom complet et une photo de votre visage.",
"id.verification.id.photo.instructions.upload": "Veuillez téléverser une photo de votre carte d'identité. Assurez-vous que la totalité de la carte d'identité rentre dans le cadre et qu'elle est bien éclairée. La taille du fichier doit être inférieure à 10 Mo. Formats pris en charge : ",
"id.verification.id.photo.instructions.upload.error.invalidFileType": "Le fichier que vous avez sélectionné n'est pas un type d'image pris en charge. Veuillez choisir parmi les formats suivants :",
"id.verification.id.photo.instructions.upload.error.fileTooLarge": "Le fichier que vous avez sélectionné est trop volumineux. Veuillez réessayer avec un fichier de moins de 10 Mo.",
"id.verification.name.check.title": "Vérifiez votre nom",
"id.verification.name.check.instructions": "Le nom ci-dessous correspond-il au nom sur votre pièce d&#39;identité avec photo ? Si ce n&#39;est pas le cas, mettez à jour le nom ci-dessous pour qu&#39;il corresponde à votre pièce d&#39;identité avec photo.",
"id.verification.name.check.mismatch.information": "Si le nom ci-dessous ne correspond pas à votre pièce d&#39;identité avec photo, votre vérification d&#39;identité sera refusée.",
"id.verification.name.error": "Veuillez entrer votre nom tel qu&#39;il apparaît sur votre pièce d&#39;identité avec photo.",
"id.verification.account.name.warning.prefix": "Veuillez noter:",
"id.verification.account.name.settings": "Paramètres du compte",
"id.verification.name.label": "Nom",
"id.verification.account.name.photo.alt": "Photo de votre pièce d'identité à soumettre.",
"id.verification.review.title": "Vérifiez vos photos",
"id.verification.review.description": "Assurez-vous que nous pourrons vérifier votre identité avec les photos et les informations fournies.",
"id.verification.review.portrait.label": "Votre portrait",
"id.verification.review.portrait.alt": "Photo de votre visage à soumettre.",
"id.verification.review.portrait.retake": "Reprendre la photo de portrait",
"id.verification.review.id.label": "Votre carte d'identité",
"id.verification.review.id.alt": "Photo de votre carte d'identité à présenter.",
"id.verification.review.id.retake": "Reprendre la photo de la pièce d'identité",
"id.verification.review.confirm": "Envoyez",
"id.verification.submission.alert.error.face": "Une photo de votre visage est requise. Veuillez reprendre votre photo de portrait.",
"id.verification.submission.alert.error.id": "Une photo de votre pièce d'identité est requise. Veuillez reprendre votre photo d'identité.",
"id.verification.submission.alert.error.name": "Un nom de compte valide est requis. Veuillez mettre à jour le nom de votre compte pour qu'il corresponde au nom sur votre pièce d'identité.",
"id.verification.submission.alert.error.unsupported": "Un ou plusieurs des fichiers que vous avez téléchargés sont dans un format non pris en charge. Veuillez choisir parmi les formats suivants :",
"id.verification.review.error": "Page de Support {siteName}",
"id.verification.submitted.title": "Vérification d'identité en cours",
"id.verification.submitted.text": "Nous avons reçu vos informations et vérifions votre identité. Vous serez averti lorsque le processus de vérification sera terminé (généralement dans les 5 jours). En attendant, vous pouvez toujours accéder à tous les contenus de cours disponibles.",
"id.verification.return.dashboard": "Retour au Tableau de bord",
"id.verification.return.course": "Revenir au cours",
"id.verification.return.generic": "Retour",
"id.verification.photo.upload.help.title": "Téléchargez une photo à la place",
"id.verification.photo.camera.help.title": "Utilisez votre appareil photo à la place",
"id.verification.photo.upload.help.text": "Si vous rencontrez des difficultés lors de l'utilisation de la capture de photo ci-dessus, vous souhaiterez peut-être télécharger une photo à la place. Pour télécharger une photo, cliquez sur le bouton ci-dessous.",
"id.verification.photo.camera.help.text": "Si vous rencontrez des difficultés pour télécharger une photo ci-dessus, vous pouvez utiliser votre appareil photo à la place. Pour utiliser votre appareil photo, cliquez sur le bouton ci-dessous.",
"id.verification.upload.help.button": "Passer en mode de téléchargement",
"id.verification.camera.help.button": "Passer en mode caméra",
"id.verification.request.camera.access.instructions": "Afin de prendre une photo à l'aide de votre webcam, vous pouvez recevoir une invite du navigateur pour accéder à votre caméra. {clickAllow}",
"id.verification.requirements.account.managed.alert": "Les paramètres de votre compte sont gérés par {managerTitle}. Si le nom sur votre pièce d'identité avec photo ne correspond pas au nom de votre compte, veuillez contacter votre administrateur {profileDataManager} ou {support} pour obtenir de l'aide avant de terminer le processus de vérification des photos.",
"id.verification.requirements.card.device.text": "Vous avez besoin d'un appareil équipé d'une caméra. Si vous recevez une invite du navigateur pour accéder à votre caméra, assurez-vous de cliquer sur {allow}.",
"id.verification.account.name.summary.alert": "Les paramètres de votre compte sont gérés par {managerTitle}. Si le nom sur votre pièce d'identité avec photo ne correspond pas au nom de votre compte, veuillez contacter votre administrateur {profileDataManager} ou {support} pour obtenir de l'aide.",
"idv.submission.alert.error": "\n Nous avons rencontré une erreur technique en essayant de soumettre la vérification d'identité.\n Il peut s'agir d'un problème temporaire. Veuillez réessayer dans quelques minutes.\n Si le problème persiste, veuillez consulter {support_link} pour obtenir de l'aide.\n ",
"id.verification.account.name.edit": "Modifier {sr}"
"id.verification.example.card.alt": "Example of a valid identification card with a full name and photo.",
"id.verification.requirements.title": "Photo Verification Requirements",
"id.verification.requirements.description": "In order to complete Photo Verification online, you will need the following:",
"id.verification.requirements.card.device.title": "Device with Camera",
"id.verification.requirements.card.device.allow": "Allow",
"id.verification.requirements.card.id.title": "Photo Identification",
"id.verification.requirements.card.id.text": "You need a valid identification card that contains your full name and photo.",
"id.verification.privacy.title": "Privacy Information",
"id.verification.privacy.need.photo.question": "Why does edX need my photo?",
"id.verification.privacy.need.photo.answer": "We use your verification photos to confirm your identity and ensure the validity of your certificate.",
"id.verification.privacy.do.with.photo.question": "What does edX do with this photo?",
"id.verification.privacy.do.with.photo.answer": "We securely encrypt your photo and send it our authorization service for review. Your photo and information are not saved or visible anywhere on edX after the verification process is complete.",
"id.verification.access.blocked.title": "Identity Verification",
"id.verification.access.blocked.enrollment": "You are not currently enrolled in a course that requires identity verification.",
"id.verification.access.blocked.pending": "You have already submitted your verification information. You will see a message on your dashboard when the verification process is complete (usually within 5 days).",
"id.verification.photo.take": "Take Photo",
"id.verification.photo.retake": "Retake Photo?",
"id.verification.photo.enable.detection": "Enable Face Detection",
"id.verification.photo.enable.detection.portrait.help.text": "If checked, a box will appear around your face. Your face can be seen clearly if the box around it is blue. If your face is not in a good position or undetectable, the box will be red.",
"id.verification.photo.enable.detection.id.help.text": "If checked, a box will appear around the face on your ID card. The face can be seen clearly if the box around it is blue. If the face is not in a good position or undetectable, the box will be red.",
"id.verification.photo.feedback.correct": "Face is in a good position.",
"id.verification.photo.feedback.two.faces": "More than one face detected.",
"id.verification.photo.feedback.no.faces": "No face detected.",
"id.verification.photo.feedback.top.left": "Incorrect position. Top left.",
"id.verification.photo.feedback.top.center": "Incorrect position. Top center.",
"id.verification.photo.feedback.top.right": "Incorrect position. Top right.",
"id.verification.photo.feedback.center.left": "Incorrect position. Center left.",
"id.verification.photo.feedback.center.center": "Incorrect position. Too close to camera.",
"id.verification.photo.feedback.center.right": "Incorrect position. Center right.",
"id.verification.photo.feedback.bottom.left": "Incorrect position. Bottom left.",
"id.verification.photo.feedback.bottom.center": "Incorrect position. Bottom center.",
"id.verification.photo.feedback.bottom.right": "Incorrect position. Bottom right.",
"id.verification.camera.access.title": "Camera Permissions",
"id.verification.camera.access.title.success": "Camera Access Enabled",
"id.verification.camera.access.title.failed": "Camera Access Failed",
"id.verification.camera.access.click.allow": "Please make sure to click \"Allow\"",
"id.verification.camera.access.enable": "Enable Camera",
"id.verification.camera.access.problems": "Having problems?",
"id.verification.camera.access.skip": "Skip and upload image files instead",
"id.verification.camera.access.success": "Looks like your camera is working and ready.",
"id.verification.camera.access.failure": "It looks like we're unable to access your camera. You will need to upload image files of you and your photo id.",
"id.verification.camera.access.failure.temporary": "It looks like we're unable to access your camera. Please verify that your webcam is connected and that you have allowed your browser to access it.",
"id.verification.camera.access.failure.temporary.chrome": "To enable camera access in Chrome:",
"id.verification.camera.access.failure.temporary.chrome.step1": "Open Chrome.",
"id.verification.camera.access.failure.temporary.chrome.step2": "Navigate to More > Settings.",
"id.verification.camera.access.failure.temporary.chrome.step2.windows": "For Windows: Alt+F, Alt+E, or F10 followed by the spacebar",
"id.verification.camera.access.failure.temporary.chrome.step2.mac": "For Mac: Command+,",
"id.verification.camera.access.failure.temporary.chrome.step3": "Under the \"Privacy and security\" tab, select \"Site Settings\" and then \"Camera.\"",
"id.verification.camera.access.failure.temporary.chrome.step4": "Under \"Blocked,\" find \"edx.org\" and select it.",
"id.verification.camera.access.failure.temporary.chrome.step5": "In the \"Permissions\" section, update the camera permissions to \"Allow.\"",
"id.verification.camera.access.failure.temporary.ie11": "To enable camera access in Internet Explorer:",
"id.verification.camera.access.failure.temporary.ie11.step1": "Open the Flash Player Settings Manager by navigating to Windows Settings > Control Panel > Flash Player.",
"id.verification.camera.access.failure.temporary.ie11.step2": "Select the \"Camera and Mic\" tab, and then select the \"Camera and Microphone Settings by Site\" button.",
"id.verification.camera.access.failure.temporary.ie11.step3": "Choose \"edx.org\" from the list of websites and change the permissions by selecting \"Allow\" in the dropdown menu.",
"id.verification.camera.access.failure.temporary.firefox": "To enable camera access in Firefox:",
"id.verification.camera.access.failure.temporary.firefox.step1": "Open Firefox.",
"id.verification.camera.access.failure.temporary.firefox.step2": "Enter \"about:preferences\" in the URL bar.",
"id.verification.camera.access.failure.temporary.firefox.step3": "Select the \"Privacy & Security\" tab, and navigate to the \"Permissions\" section.",
"id.verification.camera.access.failure.temporary.firefox.step4": "Next to \"Camera,\" select the \"Settings…\" button.",
"id.verification.camera.access.failure.temporary.firefox.step5": "In the search bar, enter \"edx.org.\"",
"id.verification.camera.access.failure.temporary.firefox.step6": "In the status column for \"edx.org,\" select \"Allow\" from the drop down.",
"id.verification.camera.access.failure.temporary.firefox.step7": "Select \"Save Changes.\"",
"id.verification.camera.access.failure.temporary.safari": "To enable camera access in Safari:",
"id.verification.camera.access.failure.temporary.safari.step1": "Open Safari.",
"id.verification.camera.access.failure.temporary.safari.step2": "Click on the Safari app menu, then select \"Preferences.\" You can also use Command+, as a keyboard shortcut.",
"id.verification.camera.access.failure.temporary.safari.step3": "Select the \"Websites\" tab and then select \"Camera.\"",
"id.verification.camera.access.failure.temporary.safari.step4": "Select \"edx.org\" and change the camera permissions to \"Allow.\"",
"id.verification.camera.access.failure.unsupported": "It looks like your browser does not support camera access.",
"id.verification.camera.access.failure.unsupported.chrome.explanation": "The Chrome browser currently does not support camera access on iOS devices, such as iPhones and iPads.",
"id.verification.camera.access.failure.unsupported.instructions": "Please use another browser to complete Identity Verification.",
"id.verification.photo.tips.title": "Helpful Photo Tips",
"id.verification.photo.tips.description": "Next, we'll need you to take a photo of your face. Please review the helpful tips below.",
"id.verification.photo.tips.list.title": "Photo Tips",
"id.verification.photo.tips.list.description": "To take a successful photo, make sure that:",
"id.verification.photo.tips.list.well.lit": "Your face is well-lit.",
"id.verification.photo.tips.list.inside.frame": "Your entire face fits inside the frame.",
"id.verification.portrait.photo.title.camera": "Take a Photo of Yourself",
"id.verification.portrait.photo.title.upload": "Upload a Photo of Yourself",
"id.verification.portrait.photo.preview.alt": "Preview of photo of user's face.",
"id.verification.portrait.photo.instructions.camera": "When your face is in position, use the Take Photo button below to take your photo.",
"id.verification.portrait.photo.instructions.upload": "Please upload a portrait photo. Ensure your entire face fits inside the frame and is well-lit. Supported formats: ",
"id.verification.camera.help.sight.question": "What if I can't see the camera image or if I can't see my photo to determine which side is visible?",
"id.verification.camera.help.sight.answer.portrait": "You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally the best position for a headshot is approximately 12-18 inches (30-45 centimeters) from the camera, with your head centered relative to the computer screen. If the photos you submit are rejected, try moving the computer or camera orientation to change the lighting angle.",
"id.verification.camera.help.sight.answer.id": "You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally, the best position for a photo of an ID card is 8-12 inches (20-30 centimeters) from the camera, with the ID card centered relative to the camera. If the photos you submit are rejected, try moving the computer or camera orientation to change the lighting angle. The most common reason for rejection is inability to read the text on the ID card.",
"id.verification.camera.help.difficulty.question.portrait": "What if I have difficulty holding my head in position relative to the camera?",
"id.verification.camera.help.difficulty.question.id": "What if I have difficulty holding my ID in position relative to the camera?",
"id.verification.camera.help.difficulty.answer": "If you require assistance with taking a photo for submission, contact edX support for additional suggestions.",
"id.verification.camera.help.upload.question": "What if I want to upload a photo instead?",
"id.verification.camera.help.upload.answer": "On the next page you will have the option to switch to upload mode. By selecting that option, you will be able to upload a photo instead.",
"id.verification.id.photo.unclear.question": "Is your ID image not clear or too blurry?",
"id.verification.id.tips.title": "Helpful ID Tips",
"id.verification.id.tips.description": "Next, we'll need you to take a photo of a valid identification card that includes your full name and photo. Please have your ID ready.",
"id.verification.id.tips.list.well.lit": "Your ID is well-lit.",
"id.verification.id.tips.list.clear": "Ensure that you can see your photo and clearly read your name.",
"id.verification.id.photo.title.camera": "Take a Photo of Your ID",
"id.verification.id.photo.title.upload": "Upload a Photo of Your ID",
"id.verification.id.photo.preview.alt": "Preview of photo ID.",
"id.verification.id.photo.instructions.camera": "When your ID is in position, use the Take Photo button below to take your photo.",
"id.verification.id.photo.instructions.upload": "Please upload an ID photo. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ",
"id.verification.id.photo.instructions.upload.error.invalidFileType": "The file you have selected is not a supported image type. Please choose from the following formats: ",
"id.verification.id.photo.instructions.upload.error.fileTooLarge": "The file you have selected is too large. Please try again with a file less than 10MB.",
"id.verification.account.name.title": "Account Name Check",
"id.verification.account.name.instructions": "The name on your account and the name on your ID must be an exact match. If not, please click \"No\" to update your account name.",
"id.verification.account.name.radio.label": "Does the name on your ID match the Account Name below?",
"id.verification.account.name.radio.yes": "Yes",
"id.verification.account.name.radio.no": "No",
"id.verification.account.name.error": "Please update account name to match the name on your ID.",
"id.verification.account.name.warning.prefix": "Please Note:",
"id.verification.account.name.settings": "Account Settings",
"id.verification.account.name.label": "Account Name",
"id.verification.account.name.photo.alt": "Photo of your ID to be submitted.",
"id.verification.account.name.save": "Save and Next",
"id.verification.review.title": "Review Your Photos",
"id.verification.review.description": "Make sure we can verify your identity with the photos and information you have provided.",
"id.verification.review.portrait.label": "Your Portrait",
"id.verification.review.portrait.alt": "Photo of your face to be submitted.",
"id.verification.review.portrait.retake": "Retake Portrait Photo",
"id.verification.review.id.label": "Your Photo ID",
"id.verification.review.id.alt": "Photo of your ID to be submitted.",
"id.verification.review.id.retake": "Retake ID Photo",
"id.verification.review.confirm": "Submit",
"id.verification.submission.alert.error.face": "A photo of your face is required. Please retake your portrait photo.",
"id.verification.submission.alert.error.id": "A photo of your ID card is required. Please retake your ID photo.",
"id.verification.submission.alert.error.name": "A valid account name is required. Please update your account name to match the name on your ID.",
"id.verification.submission.alert.error.unsupported": "One or more of the files you have uploaded is in an unsupported format. Please choose from the following: ",
"id.verification.review.error": "edX Support Page",
"id.verification.submitted.title": "Identity Verification in Progress",
"id.verification.submitted.text": "We have received your information and are verifying your identity. You will see a message on your dashboard when the verification process is complete (usually within 5 days). In the meantime, you can still access all available course content.",
"id.verification.return.dashboard": "Return to Your Dashboard",
"id.verification.return.course": "Return to Course",
"id.verification.photo.upload.help.title": "Upload a Photo Instead",
"id.verification.photo.camera.help.title": "Use Your Camera Instead",
"id.verification.photo.upload.help.text": "If you are having trouble using the photo capture above, you may want to upload a photo instead. To upload a photo, click the button below.",
"id.verification.photo.camera.help.text": "If you are having trouble uploading a photo above, you may want to use your camera instead. To use your camera, click the button below.",
"id.verification.upload.help.button": "Switch to Upload Mode",
"id.verification.camera.help.button": "Switch to Camera Mode",
"id.verification.choose.mode.title": "Photo Requirements Options",
"id.verification.choose.mode.hep.text": "To complete verification, please select one of the following options to submit photos. You will be able to switch between these options throughout the process if needed.",
"id.verification.choose.mode.radio.upload": "Upload photos from my device",
"id.verification.choose.mode.radio.camera": "Take pictures using my camera",
"id.verification.account.name.managed.alert": "Your profile settings are managed by {managerTitle}, so you are not allowed to update your name. Please contact your {profileDataManager} administrator or {support} for help.",
"id.verification.request.camera.access.instructions": "In order to take a photo using your webcam, you may receive a browser prompt for access to your camera. {clickAllow}",
"id.verification.requirements.account.managed.alert": "Your account settings are managed by {managerTitle}. If the name on your photo ID does not match the name on your account, please contact your {profileDataManager} administrator or {support} for help before completing the Photo Verification process.",
"id.verification.requirements.card.device.text": "You need a device that has a camera. If you receive a browser prompt for access to your camera, please make sure to click {allow}.",
"id.verification.account.name.summary.alert": "Your account settings are managed by {managerTitle}. If the name on your photo ID does not match the name on your account, please contact your {profileDataManager} administrator or {support} for help.",
"idv.submission.alert.error": "\n We encountered a technical error while trying to submit ID verification.\n This might be a temporary issue, so please try again in a few minutes.\n If the problem persists, please go to {support_link} for help.\n ",
"id.verification.account.name.edit": "Edit {sr}"
}

View File

@@ -1,5 +1,5 @@
{
"account.settings.message.duplicate.tpa.provider": "The {provider} account you selected is already linked to another {siteName} account.",
"account.settings.message.duplicate.tpa.provider": "The {provider} account you selected is already linked to another edX account.",
"account.settings.message.managed.settings": "Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help.",
"account.settings.message.managed.settings.support": "support",
"account.settings.page.heading": "Account Settings",
@@ -14,40 +14,16 @@
"account.settings.section.demographics.information": "Optional Information",
"account.settings.section.site.preferences": "Site Preferences",
"account.settings.section.linked.accounts": "Linked Accounts",
"account.settings.section.linked.accounts.description": "You can link your identity accounts to simplify signing in to {siteName}.",
"account.settings.section.linked.accounts.description": "You can link your identity accounts to simplify signing in to edX.",
"account.settings.field.username": "Username",
"account.settings.field.username.help.text": "The name that identifies you on {siteName}. You cannot change your username.",
"account.settings.field.username.help.text": "The name that identifies you on edX. You cannot change your username.",
"account.settings.field.full.name": "Full name",
"account.settings.field.full.name.empty": "Add name",
"account.settings.field.full.name.help.text": "The name that is used for ID verification and that appears on your certificates.",
"account.settings.field.full.name.help.text.default": "The name that appears on your public profile.",
"account.settings.field.full.name.help.text.default.certificate": "This name is selected to appear on your certificates and public-facing records.",
"account.settings.field.name.verified": "Verified name",
"account.settings.field.name.verified.help.text.verified": "This name has been verified by photo ID.",
"account.settings.field.name.verified.help.text.verified.proctored": "This name has been verified by proctoring.",
"account.settings.field.name.verified.help.text.verified.certificate": "This name has been verified by photo ID, and is selected to appear on your certificates and public-facing records.",
"account.settings.field.name.verified.help.text.verified.proctored.certificate": "This name has been verified by proctoring, and is selected to appear on your certificates and public-facing records.",
"account.settings.field.name.verified.help.text.submitted": "Verification has been submitted. This usually takes 48 hours or less. Verified name cannot be changed at this time.",
"account.settings.field.name.verified.help.text.submitted.proctored": "Your proctored exam has been submitted. Verified name cannot be changed at this time. Please check back in 2-5 days.",
"account.settings.field.name.verified.help.text.submitted.certificate": "When identity verification is successful, this name will appear on your certificates and public-facing records. Verified name cannot be changed at this time.",
"account.settings.field.name.verified.help.text.submitted.proctored.certificate": "Once your proctored exam passes review, this name will appear on your certificate and public-facing records. Verified Name cannot be changed at this time.",
"account.settings.field.name.verified.verification.help": "Enter your name as it appears on your unexpired student, work, or government-issued identification card.",
"account.settings.field.full.name.help.text.submitted": "Verification has been submitted. This usually takes 48 hours or less. Full name cannot be changed at this time.",
"account.settings.field.full.name.help.text.submitted.proctored": "Your proctored exam has been submitted. Full name cannot be changed at this time. Please check back in 2-5 days.",
"account.settings.field.full.name.help.text.submitted.certificate": "When identity verification is successful, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.",
"account.settings.field.full.name.help.text.submitted.proctored.certificate": "Once your proctored exam passes review, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.",
"account.settings.field.name.verified.success.message": "Your identity verification request has successfully completed. You now have the option of selecting which name you prefer to appear on your certificates and public-records.",
"account.settings.field.name.verified.success.message.header": "Your name change request is complete!",
"account.settings.field.name.verified.failure.message": "Your most recent identity verification attempt did not pass. Related account settings have been restored.",
"account.settings.field.name.verified.failure.message.header": "We were not able to verify your identity.",
"account.settings.field.name.verified.failure.message.help.link": "Learn more about ID verification",
"account.settings.field.name.verified.submitted.message": "Your identity verification request has been submitted and usually takes between 24 and 48 hours to complete.",
"account.settings.field.name.verified.submitted.message.certificate": "When your request is approved, your updated name will appear on all associated certificates and public-facing records.",
"account.settings.field.name.verified.submitted.message.header": "Your name change request is almost complete!",
"account.settings.field.email": "Email address (Sign in)",
"account.settings.field.email.empty": "Add email address",
"account.settings.field.email.confirmation": "Weve sent a confirmation message to {value}. Click the link in the message to update your email address.",
"account.settings.field.email.help.text": "You receive messages from {siteName} and course teams at this address.",
"account.settings.field.email.help.text": "You receive messages from edX and course teams at this address.",
"account.settings.field.secondary.email": "Recovery email address",
"account.settings.field.secondary.email.empty": "Add a recovery email address",
"account.settings.field.secondary.email.confirmation": "Weve sent a confirmation message to {value}. Click the link in the message to update your recovery email address.",
@@ -91,7 +67,7 @@
"account.settings.field.time.zone.all": "All time zones",
"account.settings.field.time.zone.country": "Country time zones",
"account.settings.section.social.media": "Social Media Links",
"account.settings.section.social.media.description": "Optionally, link your personal accounts to the social media icons on your {siteName} profile.",
"account.settings.section.social.media.description": "Optionally, link your personal accounts to the social media icons on your edX profile.",
"account.settings.field.social.platform.name.linkedin": "LinkedIn",
"account.settings.field.social.platform.name.linkedin.empty": "Add LinkedIn profile",
"account.settings.jump.nav.delete.account": "Delete My Account",
@@ -104,12 +80,6 @@
"account.settings.editable.field.action.edit": "Edit",
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
"account.settings.static.field.empty.no.admin": "No value set.",
"account.settings.field.name.certificate.select": "If checked, this name will appear on your certificates and public-facing records.",
"account.settings.field.name.modal.certificate.title": "Choose a preferred name for certificates and public-facing records",
"account.settings.field.name.modal.certificate.select": "Select a name",
"account.settings.field.name.modal.certificate.option.full": "Full Name",
"account.settings.field.name.modal.certificate.option.verified": "Verified Name",
"account.settings.field.name.modal.certificate.button.choose": "Choose name",
"account.settings.coaching.consent.welcome.header": "Lets get started.",
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
@@ -131,20 +101,17 @@
"account.settings.delete.account.before.proceeding": "Before proceeding, please {actionLink}.",
"account.settings.delete.account.header": "Delete My Account",
"account.settings.delete.account.subheader": "We're sorry to see you go!",
"account.settings.delete.account.text.1": "Please note: Deletion of your account and personal data is permanent and cannot be undone. {siteName} will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.text.2": "Once your account is deleted, you cannot use it to take courses on {siteName}.",
"account.settings.delete.account.text.2.edX": "Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.text.3.link": "Follow these instructions for printing or downloading a certificate",
"account.settings.delete.account.text.warning": "Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on {siteName}.",
"account.settings.delete.account.text.1": "Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.text.2": "Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.text.3.link": "follow the instructions for printing or downloading a certificate",
"account.settings.delete.account.text.warning": "Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.",
"account.settings.delete.account.text.change.instead": "Want to change your email, name, or password instead?",
"account.settings.delete.account.button": "Delete My Account",
"account.settings.delete.account.please.activate": "activate your account",
"account.settings.delete.account.please.confirm": "confirm your account",
"account.settings.delete.account.please.unlink": "unlink all social media accounts",
"account.settings.delete.account.modal.header": "Are you sure?",
"account.settings.delete.account.modal.text.1": "You have selected \"Delete My Account\". Deletion of your account and personal data is permanent and cannot be undone. {siteName} will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.modal.text.2": "If you proceed, you will be unable to use this account to take courses on {siteName}.",
"account.settings.delete.account.modal.text.2.edX": "If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.modal.text.1": "You have selected \"Delete My Account\". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.",
"account.settings.delete.account.modal.text.2": "If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.",
"account.settings.delete.account.modal.enter.password": "If you still wish to continue and delete your account, please enter your account password:",
"account.settings.delete.account.modal.confirm.delete": "Yes, Delete",
"account.settings.delete.account.modal.confirm.cancel": "Cancel",
@@ -155,8 +122,7 @@
"account.settings.delete.account.modal.after.header": "We're sorry to see you go! Your account will be deleted shortly.",
"account.settings.delete.account.modal.after.text": "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.",
"account.settings.delete.account.modal.after.button": "Close",
"account.settings.delete.account.text.3.edX": "You may also lose access to verified certificates and other program credentials like MicroMasters certificates. You can make a copy of these for your records before proceeding with deletion. {actionLink}.",
"account.settings.delete.account.text.3": "You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.",
"account.settings.delete.account.text.3": "You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion, {actionLink}.",
"account.settings.message.demographics.service.issue": "An error occurred attempting to retrieve or save your account information. Please try again later.",
"account.settings.field.demographics.gender": "Gender identity",
"account.settings.field.demographics.gender.empty": "Add gender identity",
@@ -187,17 +153,7 @@
"account.settings.field.demographics.future_work_sector": "Future work industry",
"account.settings.field.demographics.future_work_sector.empty": "Add work industry",
"account.settings.field.demographics.work_sector.options.empty": "Select work industry",
"account.settings.section.demographics.why": "Why does {siteName} collect this information?",
"account.settings.name.change.title.id": "This name change requires identity verification",
"account.settings.name.change.title.begin": "Before we begin",
"account.settings.name.change.warning.one": "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.",
"account.settings.name.change.warning.two": "This action cannot be undone without verifying your identity.",
"account.settings.name.change.id.name.label": "Enter your name as it appears on your unexpired student, work, or government-issued identification card.",
"account.settings.name.change.id.name.placeholder": "Enter the name on your photo ID",
"account.settings.name.change.error.valid.name": "Please enter a valid name.",
"account.settings.name.change.error.general": "A technical error occurred. Please try again.",
"account.settings.name.change.continue": "Continue",
"account.settings.name.change.cancel": "Cancel",
"account.settings.section.demographics.why": "Why does edX collect this information?",
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"account.settings.editable.field.password.reset.button.confirmation.support.link": "technical support",
"account.settings.editable.field.password.reset.button.confirmation": "We've sent a message to {email}. Click the link in the message to reset your password. Didn't receive the message? Contact {technicalSupportLink}.",
@@ -209,21 +165,21 @@
"account.settings.sso.account.disconnect.error": "There was a problem disconnecting this account. Contact support if the problem persists.",
"account.settings.sso.unlink.account": "Unlink {name} account",
"account.settings.sso.no.providers": "No accounts can be linked at this time.",
"id.verification.access.blocked.denied": "We cannot verify your identity at this time. If you have yet to activate your account, please check your spam folder for the activation email from {email}.",
"id.verification.access.blocked.denied": "You cannot verify your identity at this time. If you have yet to activate your account, please check your spam folder for the activation email from {email}.",
"id.verification.next": "Next",
"id.verification.support": "support",
"id.verification.example.card.alt": "Example of a valid identification card with a full name and photo.",
"id.verification.requirements.title": "Photo Verification Requirements",
"id.verification.requirements.description": "In order to complete Photo Verification, you will need the following:",
"id.verification.requirements.description": "In order to complete Photo Verification online, you will need the following:",
"id.verification.requirements.card.device.title": "Device with Camera",
"id.verification.requirements.card.device.allow": "Allow",
"id.verification.requirements.card.id.title": "Photo Identification Card",
"id.verification.requirements.card.id.text": "You need a valid identification card that contains your full name and photo, such as a drivers license or passport.",
"id.verification.requirements.card.id.title": "Photo Identification",
"id.verification.requirements.card.id.text": "You need a valid identification card that contains your full name and photo.",
"id.verification.privacy.title": "Privacy Information",
"id.verification.privacy.need.photo.question": "Why does {siteName} need my photo?",
"id.verification.privacy.need.photo.question": "Why does edX need my photo?",
"id.verification.privacy.need.photo.answer": "We use your verification photos to confirm your identity and ensure the validity of your certificate.",
"id.verification.privacy.do.with.photo.question": "What does {siteName} do with this photo?",
"id.verification.privacy.do.with.photo.answer": "We securely encrypt your photo and send it our authorization service for review. Your photo and information are not saved or visible anywhere on {siteName} after the verification process is complete.",
"id.verification.privacy.do.with.photo.question": "What does edX do with this photo?",
"id.verification.privacy.do.with.photo.answer": "We securely encrypt your photo and send it our authorization service for review. Your photo and information are not saved or visible anywhere on edX after the verification process is complete.",
"id.verification.access.blocked.title": "Identity Verification",
"id.verification.access.blocked.enrollment": "You are not currently enrolled in a course that requires identity verification.",
"id.verification.access.blocked.pending": "You have already submitted your verification information. You will see a message on your dashboard when the verification process is complete (usually within 5 days).",
@@ -289,58 +245,70 @@
"id.verification.photo.tips.list.well.lit": "Your face is well-lit.",
"id.verification.photo.tips.list.inside.frame": "Your entire face fits inside the frame.",
"id.verification.portrait.photo.title.camera": "Take a Photo of Yourself",
"id.verification.portrait.photo.title.upload": "Upload a Photo of Yourself",
"id.verification.portrait.photo.preview.alt": "Preview of photo of user's face.",
"id.verification.portrait.photo.instructions.camera": "When your face is in position, use the Take Photo button below to take your photo.",
"id.verification.portrait.photo.instructions.upload": "Please upload a portrait photo. Ensure your entire face fits inside the frame and is well-lit. Supported formats: ",
"id.verification.camera.help.sight.question": "What if I can't see the camera image or if I can't see my photo to determine which side is visible?",
"id.verification.camera.help.sight.answer.portrait": "You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally the best position for a headshot is approximately 12-18 inches (30-45 centimeters) from the camera, with your head centered relative to the computer screen. If the photos you submit are rejected, try moving the computer or camera orientation to change the lighting angle.",
"id.verification.camera.help.sight.answer.id": "You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally, the best position for a photo of an ID card is 8-12 inches (20-30 centimeters) from the camera, with the ID card centered relative to the camera. If the photos you submit are rejected, try moving the computer or camera orientation to change the lighting angle. The most common reason for rejection is inability to read the text on the ID card.",
"id.verification.camera.help.difficulty.question.portrait": "What if I have difficulty holding my head in position relative to the camera?",
"id.verification.camera.help.difficulty.question.id": "What if I have difficulty holding my ID in position relative to the camera?",
"id.verification.camera.help.difficulty.answer": "If you require assistance with taking a photo for submission, contact {siteName} support for additional suggestions.",
"id.verification.id.photo.unclear.question": "Is your ID card image not clear or too blurry?",
"id.verification.id.tips.title": "Helpful Identification Card Tips",
"id.verification.id.tips.description": "Next, we'll need you to take a photo of a valid identification card that includes your full name and photo, such as a drivers license or passport. Please have your ID ready.",
"id.verification.id.tips.list.well.lit": "Your identification card is well-lit.",
"id.verification.camera.help.difficulty.answer": "If you require assistance with taking a photo for submission, contact edX support for additional suggestions.",
"id.verification.camera.help.upload.question": "What if I want to upload a photo instead?",
"id.verification.camera.help.upload.answer": "On the next page you will have the option to switch to upload mode. By selecting that option, you will be able to upload a photo instead.",
"id.verification.id.photo.unclear.question": "Is your ID image not clear or too blurry?",
"id.verification.id.tips.title": "Helpful ID Tips",
"id.verification.id.tips.description": "Next, we'll need you to take a photo of a valid identification card that includes your full name and photo. Please have your ID ready.",
"id.verification.id.tips.list.well.lit": "Your ID is well-lit.",
"id.verification.id.tips.list.clear": "Ensure that you can see your photo and clearly read your name.",
"id.verification.id.photo.title.camera": "Take a Photo of Your Identification Card",
"id.verification.id.photo.title.upload": "Upload a Photo of Your Identification Card",
"id.verification.id.photo.title.camera": "Take a Photo of Your ID",
"id.verification.id.photo.title.upload": "Upload a Photo of Your ID",
"id.verification.id.photo.preview.alt": "Preview of photo ID.",
"id.verification.id.photo.instructions.camera": "When your ID is in position, use the Take Photo button below to take your photo. Please use a passport, drivers license, or another identification card that includes your full name and a picture of your face.",
"id.verification.id.photo.instructions.upload": "Please upload a photo of your identification card. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ",
"id.verification.id.photo.instructions.camera": "When your ID is in position, use the Take Photo button below to take your photo.",
"id.verification.id.photo.instructions.upload": "Please upload an ID photo. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ",
"id.verification.id.photo.instructions.upload.error.invalidFileType": "The file you have selected is not a supported image type. Please choose from the following formats: ",
"id.verification.id.photo.instructions.upload.error.fileTooLarge": "The file you have selected is too large. Please try again with a file less than 10MB.",
"id.verification.name.check.title": "Double-Check Your Name",
"id.verification.name.check.instructions": "Does the name below match the name on your photo ID? If not, update the name below to match your photo ID.",
"id.verification.name.check.mismatch.information": "If the name below does not match your photo ID, your identity verification will be denied.",
"id.verification.name.error": "Please enter your name as it appears on your photo ID.",
"id.verification.account.name.title": "Account Name Check",
"id.verification.account.name.instructions": "The name on your account and the name on your ID must be an exact match. If not, please click \"No\" to update your account name.",
"id.verification.account.name.radio.label": "Does the name on your ID match the Account Name below?",
"id.verification.account.name.radio.yes": "Yes",
"id.verification.account.name.radio.no": "No",
"id.verification.account.name.error": "Please update account name to match the name on your ID.",
"id.verification.account.name.warning.prefix": "Please Note:",
"id.verification.account.name.settings": "Account Settings",
"id.verification.name.label": "Name",
"id.verification.account.name.label": "Account Name",
"id.verification.account.name.photo.alt": "Photo of your ID to be submitted.",
"id.verification.account.name.save": "Save and Next",
"id.verification.review.title": "Review Your Photos",
"id.verification.review.description": "Make sure we can verify your identity with the photos and information you have provided.",
"id.verification.review.portrait.label": "Your Portrait",
"id.verification.review.portrait.alt": "Photo of your face to be submitted.",
"id.verification.review.portrait.retake": "Retake Portrait Photo",
"id.verification.review.id.label": "Your Identification Card",
"id.verification.review.id.alt": "Photo of your identification card to be submitted.",
"id.verification.review.id.label": "Your Photo ID",
"id.verification.review.id.alt": "Photo of your ID to be submitted.",
"id.verification.review.id.retake": "Retake ID Photo",
"id.verification.review.confirm": "Submit",
"id.verification.submission.alert.error.face": "A photo of your face is required. Please retake your portrait photo.",
"id.verification.submission.alert.error.id": "A photo of your ID card is required. Please retake your ID photo.",
"id.verification.submission.alert.error.name": "A valid account name is required. Please update your account name to match the name on your ID.",
"id.verification.submission.alert.error.unsupported": "One or more of the files you have uploaded is in an unsupported format. Please choose from the following: ",
"id.verification.review.error": "{siteName} Support Page",
"id.verification.review.error": "edX Support Page",
"id.verification.submitted.title": "Identity Verification in Progress",
"id.verification.submitted.text": "We have received your information and are verifying your identity. You will be notified when the verification process is complete (usually within 5 days). In the meantime, you can still access all available course content.",
"id.verification.submitted.text": "We have received your information and are verifying your identity. You will see a message on your dashboard when the verification process is complete (usually within 5 days). In the meantime, you can still access all available course content.",
"id.verification.return.dashboard": "Return to Your Dashboard",
"id.verification.return.course": "Return to Course",
"id.verification.return.generic": "Return",
"id.verification.photo.upload.help.title": "Upload a Photo Instead",
"id.verification.photo.camera.help.title": "Use Your Camera Instead",
"id.verification.photo.upload.help.text": "If you are having trouble using the photo capture above, you may want to upload a photo instead. To upload a photo, click the button below.",
"id.verification.photo.camera.help.text": "If you are having trouble uploading a photo above, you may want to use your camera instead. To use your camera, click the button below.",
"id.verification.upload.help.button": "Switch to Upload Mode",
"id.verification.camera.help.button": "Switch to Camera Mode",
"id.verification.choose.mode.title": "Photo Requirements Options",
"id.verification.choose.mode.hep.text": "To complete verification, please select one of the following options to submit photos. You will be able to switch between these options throughout the process if needed.",
"id.verification.choose.mode.radio.upload": "Upload photos from my device",
"id.verification.choose.mode.radio.camera": "Take pictures using my camera",
"id.verification.account.name.managed.alert": "Your profile settings are managed by {managerTitle}, so you are not allowed to update your name. Please contact your {profileDataManager} administrator or {support} for help.",
"id.verification.request.camera.access.instructions": "In order to take a photo using your webcam, you may receive a browser prompt for access to your camera. {clickAllow}",
"id.verification.requirements.account.managed.alert": "Your account settings are managed by {managerTitle}. If the name on your photo ID does not match the name on your account, please contact your {profileDataManager} administrator or {support} for help before completing the Photo Verification process.",
"id.verification.requirements.card.device.text": "You need a device that has a camera. If you receive a browser prompt for access to your camera, please make sure to click {allow}.",

View File

@@ -17,7 +17,7 @@ function AccessBlocked({ error, intl }) {
return (
<FormattedMessage
id="id.verification.access.blocked.denied"
defaultMessage="We cannot verify your identity at this time. If you have yet to activate your account, please check your spam folder for the activation email from {email}."
defaultMessage="You cannot verify your identity at this time. If you have yet to activate your account, please check your spam folder for the activation email from {email}."
description="Text that displays when user is denied from making a request, and to check their email for an activation email."
values={{
email: <strong>no-reply@registration.edx.org</strong>,

View File

@@ -1,14 +1,30 @@
import React from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './IdVerification.messages';
import IdVerificationContext from './IdVerificationContext';
function CameraHelp(props) {
const { optimizelyExperimentName } = useContext(IdVerificationContext);
return (
<div>
{ optimizelyExperimentName
&& (
<Collapsible
styling="card"
title={props.intl.formatMessage(messages['id.verification.camera.help.upload.question'])}
className="mb-4 shadow"
defaultOpen={props.isOpen}
>
<p>
{props.intl.formatMessage(messages['id.verification.camera.help.upload.answer'])}
</p>
</Collapsible>
)}
<Collapsible
styling="card"
title={props.intl.formatMessage(messages['id.verification.camera.help.sight.question'])}

View File

@@ -1,56 +1,66 @@
import React, { useContext } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Collapsible } from '@edx/paragon';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import IdVerificationContext from './IdVerificationContext';
import IdVerificationContext, { MEDIA_ACCESS } from './IdVerificationContext';
import messages from './IdVerification.messages';
function CollapsibleImageHelp(props) {
const {
userId, useCameraForId, setUseCameraForId,
shouldUseCamera, setShouldUseCamera, optimizelyExperimentName, mediaAccess,
} = useContext(IdVerificationContext);
function handleClick() {
const toggleTo = useCameraForId ? 'upload' : 'camera';
const eventName = `edx.id_verification.toggle_to.${toggleTo}`;
sendTrackEvent(eventName, {
category: 'id_verification',
user_id: userId,
});
setUseCameraForId(!useCameraForId);
setShouldUseCamera(!shouldUseCamera);
}
return (
<Collapsible
styling="card"
title={useCameraForId
? props.intl.formatMessage(messages['id.verification.photo.upload.help.title'])
: props.intl.formatMessage(messages['id.verification.photo.camera.help.title'])}
className="mb-4 shadow"
defaultOpen
>
<p data-testid="help-text">
{useCameraForId
? props.intl.formatMessage(messages['id.verification.photo.upload.help.text'])
: props.intl.formatMessage(messages['id.verification.photo.camera.help.text'])}
</p>
<Button
title={useCameraForId ? 'Upload Photo' : 'Take Photo'} // TO-DO: translation
data-testid="toggle-button"
onClick={handleClick}
style={{ marginTop: '0.5rem' }}
if (optimizelyExperimentName && mediaAccess !== MEDIA_ACCESS.DENIED && mediaAccess !== MEDIA_ACCESS.UNSUPPORTED) {
return (
<Collapsible
styling="card"
title={shouldUseCamera ? props.intl.formatMessage(messages['id.verification.photo.upload.help.title']) : props.intl.formatMessage(messages['id.verification.photo.camera.help.title'])}
className="mb-4 shadow"
defaultOpen
>
{useCameraForId
? props.intl.formatMessage(messages['id.verification.photo.upload.help.button'])
: props.intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
</Button>
</Collapsible>
);
<p data-testid="help-text">
{shouldUseCamera
? props.intl.formatMessage(messages['id.verification.photo.upload.help.text'])
: props.intl.formatMessage(messages['id.verification.photo.camera.help.text'])}
</p>
{ (mediaAccess === MEDIA_ACCESS.PENDING && !shouldUseCamera)
? (
// if a user has not enabled camera access yet, and they are trying to switch
// to camera mode, direct them to panel that requests camera access
<Link
to={{ pathname: 'request-camera-access', state: { fromPortraitCapture: props.isPortrait, fromIdCapture: !props.isPortrait } }}
className="btn btn-primary"
data-testid="access-link"
>
{props.intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
</Link>
)
: (
<Button
title={shouldUseCamera ? 'Upload Portrait Photo' : 'Take Portrait Photo'}
data-testid="toggle-button"
onClick={handleClick}
style={{ marginTop: '0.5rem' }}
>
{shouldUseCamera ? props.intl.formatMessage(messages['id.verification.photo.upload.help.button']) : props.intl.formatMessage(messages['id.verification.photo.camera.help.button'])}
</Button>
)}
</Collapsible>
);
}
return null;
}
CollapsibleImageHelp.propTypes = {
intl: intlShape.isRequired,
isPortrait: PropTypes.bool.isRequired,
};
export default injectIntl(CollapsibleImageHelp);

View File

@@ -11,6 +11,11 @@ const messages = defineMessages({
defaultMessage: 'support',
description: 'Website support.',
},
'id.verification.continue.upload': {
id: 'id.verification.continue.upload',
defaultMessage: 'Continue with Upload',
description: 'Button to continue with upload.',
},
'id.verification.example.card.alt': {
id: 'id.verification.example.card.alt',
defaultMessage: 'Example of a valid identification card with a full name and photo.',
@@ -23,7 +28,7 @@ const messages = defineMessages({
},
'id.verification.requirements.description': {
id: 'id.verification.requirements.description',
defaultMessage: 'In order to complete Photo Verification, you will need the following:',
defaultMessage: 'In order to complete Photo Verification online, you will need the following:',
description: 'Description for the Photo Verification Requirements page.',
},
'id.verification.requirements.card.device.title': {
@@ -38,12 +43,12 @@ const messages = defineMessages({
},
'id.verification.requirements.card.id.title': {
id: 'id.verification.requirements.card.id.title',
defaultMessage: 'Photo Identification Card',
defaultMessage: 'Photo Identification',
description: 'Title for the Photo Identification requirement card.',
},
'id.verification.requirements.card.id.text': {
id: 'id.verification.requirements.card.id.text',
defaultMessage: 'You need a valid identification card that contains your full name and photo, such as a drivers license or passport.',
defaultMessage: 'You need a valid identification card that contains your full name and photo.',
description: 'Text that explains that the user needs a photo ID.',
},
'id.verification.privacy.title': {
@@ -396,11 +401,26 @@ const messages = defineMessages({
defaultMessage: 'Take a Photo of Yourself',
description: 'Title for the Portrait Photo page if camera access is enabled.',
},
'id.verification.portrait.photo.title.upload': {
id: 'id.verification.portrait.photo.title.upload',
defaultMessage: 'Upload a Photo of Yourself',
description: 'Title for the Portrait Photo page if camera access is disabled.',
},
'id.verification.portrait.photo.preview.alt': {
id: 'id.verification.portrait.photo.preview.alt',
defaultMessage: 'Preview of photo of user\'s face.',
description: 'Alt text for the portrait photo preview.',
},
'id.verification.portrait.photo.instructions.camera': {
id: 'id.verification.portrait.photo.instructions.camera',
defaultMessage: 'When your face is in position, use the Take Photo button below to take your photo.',
description: 'Instructions to use the camera to take a portrait photo..',
},
'id.verification.portrait.photo.instructions.upload': {
id: 'id.verification.portrait.photo.instructions.upload',
defaultMessage: 'Please upload a portrait photo. Ensure your entire face fits inside the frame and is well-lit. Supported formats: ',
description: 'Instructions for portrait photo upload.',
},
'id.verification.camera.help.sight.question': {
id: 'id.verification.camera.help.sight.question',
defaultMessage: 'What if I can\'t see the camera image or if I can\'t see my photo to determine which side is visible?',
@@ -431,24 +451,34 @@ const messages = defineMessages({
defaultMessage: 'If you require assistance with taking a photo for submission, contact {siteName} support for additional suggestions.',
description: 'Confirming what to do if the user has difficult holding their head relative to the camera.',
},
'id.verification.camera.help.upload.question': {
id: 'id.verification.camera.help.upload.question',
defaultMessage: 'What if I want to upload a photo instead?',
description: 'Question on what to do if the user would like to upload a photo instead.',
},
'id.verification.camera.help.upload.answer': {
id: 'id.verification.camera.help.upload.answer',
defaultMessage: 'On the next page you will have the option to switch to upload mode. By selecting that option, you will be able to upload a photo instead.',
description: 'Confirming what to do if the user would like to upload a photo.',
},
'id.verification.id.photo.unclear.question': {
id: 'id.verification.id.photo.unclear.question',
defaultMessage: 'Is your ID card image not clear or too blurry?',
defaultMessage: 'Is your ID image not clear or too blurry?',
description: 'Question on what to do if the user\'s ID image is unclear',
},
'id.verification.id.tips.title': {
id: 'id.verification.id.tips.title',
defaultMessage: 'Helpful Identification Card Tips',
defaultMessage: 'Helpful ID Tips',
description: 'Title for the ID Tips page.',
},
'id.verification.id.tips.description': {
id: 'id.verification.id.tips.description',
defaultMessage: 'Next, we\'ll need you to take a photo of a valid identification card that includes your full name and photo, such as a drivers license or passport. Please have your ID ready.',
defaultMessage: 'Next, we\'ll need you to take a photo of a valid identification card that includes your full name and photo. Please have your ID ready.',
description: 'Description for the ID Tips page.',
},
'id.verification.id.tips.list.well.lit': {
id: 'id.verification.id.tips.list.well.lit',
defaultMessage: 'Your identification card is well-lit.',
defaultMessage: 'Your ID is well-lit.',
description: 'Tip to ensure ID is well lit.',
},
'id.verification.id.tips.list.clear': {
@@ -458,12 +488,12 @@ const messages = defineMessages({
},
'id.verification.id.photo.title.camera': {
id: 'id.verification.id.photo.title.camera',
defaultMessage: 'Take a Photo of Your Identification Card',
defaultMessage: 'Take a Photo of Your ID',
description: 'Title for the ID Photo page if camera access is enabled.',
},
'id.verification.id.photo.title.upload': {
id: 'id.verification.id.photo.title.upload',
defaultMessage: 'Upload a Photo of Your Identification Card',
defaultMessage: 'Upload a Photo of Your ID',
description: 'Title for the ID Photo page if camera access is disabled.',
},
'id.verification.id.photo.preview.alt': {
@@ -473,12 +503,12 @@ const messages = defineMessages({
},
'id.verification.id.photo.instructions.camera': {
id: 'id.verification.id.photo.instructions.camera',
defaultMessage: 'When your ID is in position, use the Take Photo button below to take your photo. Please use a passport, drivers license, or another identification card that includes your full name and a picture of your face.',
defaultMessage: 'When your ID is in position, use the Take Photo button below to take your photo.',
description: 'Instructions to use the camera to take an ID photo.',
},
'id.verification.id.photo.instructions.upload': {
id: 'id.verification.id.photo.instructions.upload',
defaultMessage: 'Please upload a photo of your identification card. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ',
defaultMessage: 'Please upload an ID photo. Ensure the entire ID fits inside the frame and is well-lit. The file size must be under 10 MB. Supported formats: ',
description: 'Instructions for ID photo upload.',
},
'id.verification.id.photo.instructions.upload.error.invalidFileType': {
@@ -491,25 +521,35 @@ const messages = defineMessages({
defaultMessage: 'The file you have selected is too large. Please try again with a file less than 10MB.',
description: 'Error message for file upload that is larger than 10MB.',
},
'id.verification.name.check.title': {
id: 'id.verification.name.check.title',
defaultMessage: 'Double-Check Your Name',
description: 'Title for the page where a user double-checks that their name is correct.',
'id.verification.account.name.title': {
id: 'id.verification.account.name.title',
defaultMessage: 'Account Name Check',
description: 'Title for the Account Name Check page.',
},
'id.verification.name.check.instructions': {
id: 'id.verification.name.check.instructions',
defaultMessage: 'Does the name below match the name on your photo ID? If not, update the name below to match your photo ID.',
description: 'Text to instruct the user to check that the name displayed on the page matches what is on their photo ID.',
'id.verification.account.name.instructions': {
id: 'id.verification.account.name.instructions',
defaultMessage: 'The name on your account and the name on your ID must be an exact match. If not, please click "No" to update your account name.',
description: 'Text to verify that the account name matches the name on the ID photo.',
},
'id.verification.name.check.mismatch.information': {
id: 'id.verification.name.check.mismatch.information',
defaultMessage: 'If the name below does not match your photo ID, your identity verification will be denied.',
description: 'Text to inform the user that if the name displayed on the page does not match what is on their photo ID, identity verification will be denied.',
'id.verification.account.name.radio.label': {
id: 'id.verification.account.name.radio.label',
defaultMessage: 'Does the name on your ID match the Account Name below?',
description: 'Question to ask the user whether their account name match the name on their ID card.',
},
'id.verification.name.error': {
id: 'id.verification.name.error',
defaultMessage: 'Please enter your name as it appears on your photo ID.',
description: 'Error that shows when the user needs to update their name to match the name on their ID.',
'id.verification.account.name.radio.yes': {
id: 'id.verification.account.name.radio.yes',
defaultMessage: 'Yes',
description: 'The radio button that says the account name matches.',
},
'id.verification.account.name.radio.no': {
id: 'id.verification.account.name.radio.no',
defaultMessage: 'No',
description: 'The radio button that says the account name does not match.',
},
'id.verification.account.name.error': {
id: 'id.verification.account.name.error',
defaultMessage: 'Please update account name to match the name on your ID.',
description: 'Error that shows when the user needs to update their account name to match the name on their ID.',
},
'id.verification.account.name.warning.prefix': {
id: 'id.verification.account.name.warning.prefix',
@@ -521,16 +561,21 @@ const messages = defineMessages({
defaultMessage: 'Account Settings',
description: 'Link to Account Settings.',
},
'id.verification.name.label': {
id: 'id.verification.name.label',
defaultMessage: 'Name',
description: 'Label for name input.',
'id.verification.account.name.label': {
id: 'id.verification.account.name.label',
defaultMessage: 'Account Name',
description: 'Label for account name input.',
},
'id.verification.account.name.photo.alt': {
id: 'id.verification.account.name.photo.alt',
defaultMessage: 'Photo of your ID to be submitted.',
description: 'Alt text for the photo of the user\'s ID.',
},
'id.verification.account.name.save': {
id: 'id.verification.account.name.save',
defaultMessage: 'Save and Next',
description: 'Button to save the account name.',
},
'id.verification.review.title': {
id: 'id.verification.review.title',
defaultMessage: 'Review Your Photos',
@@ -558,12 +603,12 @@ const messages = defineMessages({
},
'id.verification.review.id.label': {
id: 'id.verification.review.id.label',
defaultMessage: 'Your Identification Card',
defaultMessage: 'Your Photo ID',
description: 'Label for the Photo ID card.',
},
'id.verification.review.id.alt': {
id: 'id.verification.review.id.alt',
defaultMessage: 'Photo of your identification card to be submitted.',
defaultMessage: 'Photo of your ID to be submitted.',
description: 'Alt text for the ID photo.',
},
'id.verification.review.id.retake': {
@@ -608,7 +653,7 @@ const messages = defineMessages({
},
'id.verification.submitted.text': {
id: 'id.verification.submitted.text',
defaultMessage: 'We have received your information and are verifying your identity. You will be notified when the verification process is complete (usually within 5 days). In the meantime, you can still access all available course content.',
defaultMessage: 'We have received your information and are verifying your identity. You will see a message on your dashboard when the verification process is complete (usually within 5 days). In the meantime, you can still access all available course content.',
description: 'Text confirming that ID verification request has been received.',
},
'id.verification.return.dashboard': {
@@ -621,11 +666,6 @@ const messages = defineMessages({
defaultMessage: 'Return to Course',
description: 'Return to the course which ID verification was accessed from.',
},
'id.verification.return.generic': {
id: 'id.verification.return.generic',
defaultMessage: 'Return',
description: 'Button to return to the user\'s original location.',
},
'id.verification.photo.upload.help.title': {
id: 'id.verification.photo.upload.help.title',
defaultMessage: 'Upload a Photo Instead',
@@ -656,6 +696,26 @@ const messages = defineMessages({
defaultMessage: 'Switch to Camera Mode',
description: 'Button used to switch to camera mode.',
},
'id.verification.choose.mode.title': {
id: 'id.verification.choose.mode.title',
defaultMessage: 'Photo Requirements Options',
description: 'Title for section that allows user to choose photo mode.',
},
'id.verification.choose.mode.help.text': {
id: 'id.verification.choose.mode.hep.text',
defaultMessage: 'To complete verification, please select one of the following options to submit photos. You will be able to switch between these options throughout the process if needed.',
description: 'Help text for section that allows user to choose photo mode.',
},
'id.verification.choose.mode.radio.upload': {
id: 'id.verification.choose.mode.radio.upload',
defaultMessage: 'Upload photos from my device',
description: 'Radio button to choose to upload photos.',
},
'id.verification.choose.mode.radio.camera': {
id: 'id.verification.choose.mode.radio.camera',
defaultMessage: 'Take pictures using my camera',
description: 'Radio button to choose to use camera for photos.',
},
});
export default messages;

View File

@@ -4,21 +4,23 @@ import { AppContext } from '@edx/frontend-platform/react';
import { getProfileDataManager } from '../account-settings/data/service';
import PageLoading from '../account-settings/PageLoading';
import { useAsyncCall } from '../hooks';
import { IDLE_STATUS, LOADING_STATUS, SUCCESS_STATUS } from '../constants';
import { getExistingIdVerification, getEnrollments } from './data/service';
import AccessBlocked from './AccessBlocked';
import { hasGetUserMediaSupport } from './getUserMediaShim';
import IdVerificationContext, { MEDIA_ACCESS, ERROR_REASONS, VERIFIED_MODES } from './IdVerificationContext';
import { VerifiedNameContext } from './VerifiedNameContext';
export default function IdVerificationContextProvider({ children }) {
const { authenticatedUser } = useContext(AppContext);
const { verifiedNameHistoryCallStatus, verifiedName } = useContext(VerifiedNameContext);
const idVerificationData = useAsyncCall(getExistingIdVerification);
const enrollmentsData = useAsyncCall(getEnrollments);
const [existingIdVerification, setExistingIdVerification] = useState(null);
useEffect(() => {
// Call verification status endpoint to check whether we can verify.
(async () => {
const existingIdV = await getExistingIdVerification();
setExistingIdVerification(existingIdV);
})();
}, []);
const [facePhotoFile, setFacePhotoFile] = useState(null);
const [idPhotoFile, setIdPhotoFile] = useState(null);
@@ -28,6 +30,35 @@ export default function IdVerificationContextProvider({ children }) {
hasGetUserMediaSupport ? MEDIA_ACCESS.PENDING : MEDIA_ACCESS.UNSUPPORTED,
);
const [canVerify, setCanVerify] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
// Check for an existing verification attempt
if (existingIdVerification && !existingIdVerification.canVerify) {
const { status } = existingIdVerification;
setCanVerify(false);
if (status === 'pending' || status === 'approved') {
setError(ERROR_REASONS.EXISTING_REQUEST);
} else {
setError(ERROR_REASONS.CANNOT_VERIFY);
}
}
}, [existingIdVerification]);
useEffect(() => {
// Check whether the learner is enrolled in a verified course mode.
(async () => {
/* eslint-disable arrow-body-style */
const enrollments = await getEnrollments();
const verifiedEnrollments = enrollments.filter((enrollment) => {
return VERIFIED_MODES.includes(enrollment.mode);
});
if (verifiedEnrollments.length === 0) {
setCanVerify(false);
setError(ERROR_REASONS.COURSE_ENROLLMENT);
}
})();
}, []);
const [profileDataManager, setProfileDataManager] = useState(null);
useEffect(() => {
// Determine if the user's profile data is managed by a third-party identity provider.
@@ -45,31 +76,13 @@ export default function IdVerificationContextProvider({ children }) {
}
}, [authenticatedUser]);
// Default to upload for the ID image
const [useCameraForId, setUseCameraForId] = useState(false);
const [optimizelyExperimentName, setOptimizelyExperimentName] = useState('');
const [shouldUseCamera, setShouldUseCamera] = useState(false);
// If the user reaches the end of the flow and goes back to retake their photos,
// this flag ensures that they are directed straight back to the summary panel
const [reachedSummary, setReachedSummary] = useState(false);
let canVerify = true;
let error = '';
let existingIdVerification;
if (idVerificationData?.data) {
existingIdVerification = idVerificationData.data;
}
if (enrollmentsData.status === SUCCESS_STATUS && enrollmentsData?.data) {
const verifiedEnrollments = enrollmentsData.data.filter((enrollment) => (
VERIFIED_MODES.includes(enrollment.mode)
));
if (verifiedEnrollments.length === 0) {
canVerify = false;
error = ERROR_REASONS.COURSE_ENROLLMENT;
}
}
const contextValue = {
existingIdVerification,
facePhotoFile,
@@ -78,27 +91,30 @@ export default function IdVerificationContextProvider({ children }) {
mediaStream,
mediaAccess,
userId: authenticatedUser.userId,
// If the learner has an applicable verified name, then this should override authenticatedUser.name
// when determining the context value nameOnAccount.
nameOnAccount: verifiedName || authenticatedUser.name,
nameOnAccount: authenticatedUser.name,
profileDataManager,
useCameraForId,
optimizelyExperimentName,
shouldUseCamera,
reachedSummary,
setExistingIdVerification,
setFacePhotoFile,
setIdPhotoFile,
setIdPhotoName,
setUseCameraForId,
setOptimizelyExperimentName,
setShouldUseCamera,
setReachedSummary,
tryGetUserMedia: async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
setMediaAccess(MEDIA_ACCESS.GRANTED);
setMediaStream(stream);
setShouldUseCamera(true);
// stop the stream, as we are not using it yet
const tracks = stream.getTracks();
tracks.forEach(track => track.stop());
} catch (err) {
setMediaAccess(MEDIA_ACCESS.DENIED);
setShouldUseCamera(false);
}
},
stopUserMedia: () => {
@@ -110,9 +126,8 @@ export default function IdVerificationContextProvider({ children }) {
},
};
const loadingStatuses = [IDLE_STATUS, LOADING_STATUS];
// If we are waiting for verification status or verified name history endpoint, show spinner.
if (loadingStatuses.includes(idVerificationData.status) || loadingStatuses.includes(verifiedNameHistoryCallStatus)) {
// If we are waiting for verification status endpoint, show spinner.
if (!existingIdVerification) {
return <PageLoading srMessage="Loading verification status" />;
}

View File

@@ -3,7 +3,6 @@ import { connect } from 'react-redux';
import {
Route, Switch, Redirect, useRouteMatch, useLocation,
} from 'react-router-dom';
import camelCase from 'lodash.camelcase';
import qs from 'qs';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Modal, Button } from '@edx/paragon';
@@ -12,8 +11,8 @@ import { idVerificationSelector } from './data/selectors';
import './getUserMediaShim';
import IdVerificationContextProvider from './IdVerificationContextProvider';
import { VerifiedNameContextProvider } from './VerifiedNameContext';
import ReviewRequirementsPanel from './panels/ReviewRequirementsPanel';
import ChooseModePanel from './panels/ChooseModePanel';
import RequestCameraAccessPanel from './panels/RequestCameraAccessPanel';
import PortraitPhotoContextPanel from './panels/PortraitPhotoContextPanel';
import TakePortraitPhotoPanel from './panels/TakePortraitPhotoPanel';
@@ -32,16 +31,16 @@ function IdVerificationPage(props) {
const [isModalOpen, setIsModalOpen] = useState(false);
// Save query params in order to route back to the correct location later
// Course run key is passed as a query string
useEffect(() => {
if (search) {
const parsedQueryParams = qs.parse(search, {
const parsed = qs.parse(search, {
ignoreQueryPrefix: true,
interpretNumericEntities: true,
});
Object.entries(parsedQueryParams).forEach(([key, value]) => {
sessionStorage.setItem(camelCase(key), value);
});
if (Object.prototype.hasOwnProperty.call(parsed, 'course_id') && parsed.course_id) {
sessionStorage.setItem('courseRunKey', parsed.course_id);
}
}
}, [search]);
@@ -52,21 +51,20 @@ function IdVerificationPage(props) {
<div className="page__id-verification container-fluid py-5">
<div className="row">
<div className="col-lg-6 col-md-8">
<VerifiedNameContextProvider>
<IdVerificationContextProvider>
<Switch>
<Route path={`${path}/review-requirements`} component={ReviewRequirementsPanel} />
<Route path={`${path}/request-camera-access`} component={RequestCameraAccessPanel} />
<Route path={`${path}/portrait-photo-context`} component={PortraitPhotoContextPanel} />
<Route path={`${path}/take-portrait-photo`} component={TakePortraitPhotoPanel} />
<Route path={`${path}/id-context`} component={IdContextPanel} />
<Route path={`${path}/get-name-id`} component={GetNameIdPanel} />
<Route path={`${path}/take-id-photo`} component={TakeIdPhotoPanel} />
<Route path={`${path}/summary`} component={SummaryPanel} />
<Route path={`${path}/submitted`} component={SubmittedPanel} />
</Switch>
</IdVerificationContextProvider>
</VerifiedNameContextProvider>
<IdVerificationContextProvider>
<Switch>
<Route path={`${path}/review-requirements`} component={ReviewRequirementsPanel} />
<Route path={`${path}/choose-mode`} component={ChooseModePanel} />
<Route path={`${path}/request-camera-access`} component={RequestCameraAccessPanel} />
<Route path={`${path}/portrait-photo-context`} component={PortraitPhotoContextPanel} />
<Route path={`${path}/take-portrait-photo`} component={TakePortraitPhotoPanel} />
<Route path={`${path}/id-context`} component={IdContextPanel} />
<Route path={`${path}/get-name-id`} component={GetNameIdPanel} />
<Route path={`${path}/take-id-photo`} component={TakeIdPhotoPanel} />
<Route path={`${path}/summary`} component={SummaryPanel} />
<Route path={`${path}/submitted`} component={SubmittedPanel} />
</Switch>
</IdVerificationContextProvider>
</div>
<div className="col-lg-6 col-md-4 pt-md-0 pt-4 text-right">
<Button variant="link" className="px-0" onClick={() => setIsModalOpen(true)}>

View File

@@ -26,9 +26,7 @@ export default function ImageFileUpload({ onFileChange, intl }) {
} else {
setError(null);
const fileReader = new FileReader();
fileReader.addEventListener('load', () => {
onFileChange(fileReader.result);
});
fileReader.addEventListener('load', () => onFileChange(fileReader.result));
fileReader.readAsDataURL(fileObject);
}
}, []);

View File

@@ -1,31 +0,0 @@
import React, { createContext } from 'react';
import PropTypes from 'prop-types';
import { getVerifiedNameHistory } from '../account-settings/data/service';
import { getMostRecentApprovedOrPendingVerifiedName } from '../utils';
import { useAsyncCall } from '../hooks';
import { SUCCESS_STATUS } from '../constants';
export const VerifiedNameContext = createContext();
export function VerifiedNameContextProvider({ children }) {
const verifiedNameHistoryData = useAsyncCall(getVerifiedNameHistory);
let verifiedName = '';
const { status, data } = verifiedNameHistoryData;
if (status === SUCCESS_STATUS && data) {
const { results } = data;
verifiedName = getMostRecentApprovedOrPendingVerifiedName(results);
}
const value = {
verifiedNameHistoryCallStatus: status,
verifiedName,
};
return (<VerifiedNameContext.Provider value={value}>{children}</VerifiedNameContext.Provider>);
}
VerifiedNameContextProvider.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@@ -14,6 +14,14 @@
max-height: 10rem;
}
}
.form-check {
padding: 0.5rem 0.5rem 1rem;
.form-check-label {
margin-left: 0.5rem;
padding-top: 0.2rem;
font-weight: normal;
}
}
}
.action-row {
display: flex;

View File

@@ -44,7 +44,7 @@ export async function getEnrollments() {
const { data } = await getAuthenticatedHttpClient().get(url, requestConfig);
return data;
} catch (e) {
return {};
return [];
}
}
@@ -64,6 +64,7 @@ export async function submitIdVerification(verificationData) {
facePhotoFile: 'face_image',
idPhotoFile: 'photo_id_image',
idPhotoName: 'full_name',
optimizelyExperimentName: 'experiment_name',
};
const postData = {};
// Don't include blank/null/undefined values.

View File

@@ -0,0 +1,60 @@
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages';
function ChooseModePanel(props) {
const panelSlug = 'choose-mode';
const { shouldUseCamera, setShouldUseCamera } = useContext(IdVerificationContext);
function onPhotoModeChange(value) {
setShouldUseCamera(value);
}
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.choose.mode.title'])}
>
<p>
{props.intl.formatMessage(messages['id.verification.choose.mode.help.text'])}
</p>
<fieldset>
<Form.Group controlId="formChoosePhotoOption" style={{ marginLeft: '1.25rem' }}>
<Form.Check
type="radio"
id="useUploadMode"
label={props.intl.formatMessage(messages['id.verification.choose.mode.radio.upload'])}
name="photoMode"
checked={!shouldUseCamera}
onChange={() => onPhotoModeChange(false)}
/>
<Form.Check
type="radio"
id="useCameraMode"
label={props.intl.formatMessage(messages['id.verification.choose.mode.radio.camera'])}
name="photoMode"
checked={shouldUseCamera}
onChange={() => onPhotoModeChange(true)}
/>
</Form.Group>
</fieldset>
<div className="action-row">
<Link to={useNextPanelSlug(panelSlug)} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}
</Link>
</div>
</BasePanel>
);
}
ChooseModePanel.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ChooseModePanel);

View File

@@ -1,9 +1,11 @@
import React, {
useContext, useEffect, useRef,
useContext, useState, useEffect, useRef,
} from 'react';
import { Form } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { Hyperlink, Form } from '@edx/paragon';
import { Link, useHistory } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
@@ -12,27 +14,76 @@ import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages';
function GetNameIdPanel(props) {
const { push, location } = useHistory();
const nameInputRef = useRef();
const { push } = useHistory();
const panelSlug = 'get-name-id';
const [nameMatches, setNameMatches] = useState(true);
const nameInputRef = useRef();
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { nameOnAccount, idPhotoName, setIdPhotoName } = useContext(IdVerificationContext);
const {
nameOnAccount, userId, profileDataManager, idPhotoName, setIdPhotoName,
} = useContext(IdVerificationContext);
const nameOnAccountValue = nameOnAccount || '';
const invalidName = !nameMatches && (!idPhotoName || idPhotoName === nameOnAccount);
const blankName = !nameOnAccount && !idPhotoName;
useEffect(() => {
if (idPhotoName === null) {
setIdPhotoName(nameOnAccountValue);
}
setIdPhotoName(null);
}, []);
if (location.state?.fromSummary && nameInputRef.current) {
useEffect(() => {
if (!nameMatches && nameInputRef.current) {
nameInputRef.current.focus();
}
}, []);
if (!nameMatches) {
sendTrackEvent('edx.id_verification.name_change', {
category: 'id_verification',
user_id: userId,
});
}
if (blankName) {
setNameMatches(false);
}
}, [nameMatches, blankName]);
function getNameValue() {
if (!nameMatches) {
// Explicitly check for null, as an empty string should still be used here
if (idPhotoName === null) {
return nameOnAccountValue;
}
return idPhotoName;
}
return nameOnAccountValue;
}
function getErrorMessage() {
if (profileDataManager) {
return (
<FormattedMessage
id="id.verification.account.name.managed.alert"
defaultMessage="Your profile settings are managed by {managerTitle}, so you are not allowed to update your name. Please contact your {profileDataManager} administrator or {support} for help."
description="Alert message informing the user their account name is managed by a third party."
values={{
managerTitle: <strong>{profileDataManager}</strong>,
profileDataManager,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
{props.intl.formatMessage(messages['id.verification.support'])}
</Hyperlink>
),
}}
/>
);
}
return props.intl.formatMessage(messages['id.verification.account.name.error']);
}
function handleSubmit(e) {
e.preventDefault();
if (idPhotoName) {
// If the input is empty, or if no changes have been made to the
// mismatching name, the user should not be able to proceed.
if (!invalidName && !blankName) {
push(nextPanelSlug);
}
}
@@ -40,51 +91,79 @@ function GetNameIdPanel(props) {
return (
<BasePanel
name={panelSlug}
title={props.intl.formatMessage(messages['id.verification.name.check.title'])}
title={props.intl.formatMessage(messages['id.verification.account.name.title'])}
>
<p>
{props.intl.formatMessage(messages['id.verification.name.check.instructions'])}
</p>
<p>
{props.intl.formatMessage(messages['id.verification.name.check.mismatch.information'])}
{props.intl.formatMessage(messages['id.verification.account.name.instructions'])}
</p>
<Form onSubmit={handleSubmit}>
<Form.Group>
<Form.Label className="font-weight-bold" htmlFor="photo-id-name">
{props.intl.formatMessage(messages['id.verification.name.label'])}
<Form.Label htmlFor="nameMatchesYes">
{props.intl.formatMessage(messages['id.verification.account.name.radio.label'])}
</Form.Label>
<Form.Row>
<Form.Check
type="radio"
id="nameMatchesYes"
name="nameMatches"
data-testid="name-matches-yes"
label={props.intl.formatMessage(messages['id.verification.account.name.radio.yes'])}
checked={nameMatches}
disabled={!nameOnAccount}
inline
onChange={() => {
setNameMatches(true);
setIdPhotoName(null);
}}
/>
<Form.Check
type="radio"
id="nameMatchesNo"
name="nameMatches"
data-testid="name-matches-no"
label={props.intl.formatMessage(messages['id.verification.account.name.radio.no'])}
checked={!nameMatches}
disabled={!nameOnAccount}
inline
onChange={() => setNameMatches(false)}
/>
</Form.Row>
</Form.Group>
<Form.Group>
<Form.Label htmlFor="photo-id-name">
{props.intl.formatMessage(messages['id.verification.account.name.label'])}
</Form.Label>
<Form.Control
controlId="photo-id-name"
size="lg"
type="text"
ref={nameInputRef}
isInvalid={!idPhotoName}
readOnly={nameMatches || profileDataManager}
isInvalid={invalidName || blankName}
aria-describedby="photo-id-name-feedback"
value={idPhotoName}
value={getNameValue()}
onChange={e => setIdPhotoName(e.target.value)}
data-testid="name-input"
/>
{!idPhotoName && (
<Form.Control.Feedback
id="photo-id-name-feedback"
data-testid="id-name-feedback-message"
type="invalid"
>
{props.intl.formatMessage(messages['id.verification.name.error'])}
</Form.Control.Feedback>
)}
<Form.Control.Feedback id="photo-id-name-feedback" type="invalid">
{getErrorMessage()}
</Form.Control.Feedback>
</Form.Group>
</Form>
<div className="action-row">
<Link
to={nextPanelSlug}
className={`btn btn-primary ${!idPhotoName && 'disabled'}`}
className={`btn btn-primary ${(invalidName || blankName) && 'disabled'}`}
data-testid="next-button"
aria-disabled={!idPhotoName}
aria-disabled={invalidName || blankName}
>
{props.intl.formatMessage(messages['id.verification.next'])}
{
!nameMatches
? props.intl.formatMessage(messages['id.verification.account.name.save'])
: props.intl.formatMessage(messages['id.verification.next'])
}
</Link>
</div>
</BasePanel>

View File

@@ -1,11 +1,10 @@
import React, { useEffect, useContext } from 'react';
import React, { useState, useEffect, useContext } from 'react';
import { Link } from 'react-router-dom';
import Bowser from 'bowser';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useRedirect } from '../../hooks';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
import IdVerificationContext, { MEDIA_ACCESS } from '../IdVerificationContext';
@@ -15,11 +14,12 @@ import { UnsupportedCameraDirectionsPanel } from './UnsupportedCameraDirectionsP
import messages from '../IdVerification.messages';
function RequestCameraAccessPanel(props) {
const { location: returnUrl, text: returnText } = useRedirect();
const [returnUrl, setReturnUrl] = useState('dashboard');
const [returnText, setReturnText] = useState('id.verification.return.dashboard');
const panelSlug = 'request-camera-access';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const {
tryGetUserMedia, mediaAccess, userId,
tryGetUserMedia, mediaAccess, userId, optimizelyExperimentName,
} = useContext(IdVerificationContext);
const browserName = Bowser.parse(window.navigator.userAgent).browser.name;
@@ -38,6 +38,15 @@ function RequestCameraAccessPanel(props) {
}
}, [mediaAccess, userId]);
// If the user accessed IDV through a course,
// link back to that course rather than the dashboard
useEffect(() => {
if (sessionStorage.getItem('courseRunKey')) {
setReturnUrl(`courses/${sessionStorage.getItem('courseRunKey')}`);
setReturnText('id.verification.return.course');
}
}, []);
const getTitle = () => {
if (mediaAccess === MEDIA_ACCESS.GRANTED) {
return props.intl.formatMessage(messages['id.verification.camera.access.title.success']);
@@ -48,12 +57,18 @@ function RequestCameraAccessPanel(props) {
return props.intl.formatMessage(messages['id.verification.camera.access.title']);
};
const returnLink = (
const returnToDashboardLink = (
<a className="btn btn-primary" href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}>
{props.intl.formatMessage(messages[returnText])}
</a>
);
const nextButtonLink = (
<Link to={nextPanelSlug} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.continue.upload'])}
</Link>
);
return (
<BasePanel
name={panelSlug}
@@ -99,7 +114,7 @@ function RequestCameraAccessPanel(props) {
</p>
<EnableCameraDirectionsPanel browserName={browserName} intl={props.intl} />
<div className="action-row">
{returnLink}
{optimizelyExperimentName ? nextButtonLink : returnToDashboardLink}
</div>
</div>
)}
@@ -111,7 +126,7 @@ function RequestCameraAccessPanel(props) {
</p>
<UnsupportedCameraDirectionsPanel browserName={browserName} intl={props.intl} />
<div className="action-row">
{returnLink}
{optimizelyExperimentName ? nextButtonLink : returnToDashboardLink}
</div>
</div>
)}

View File

@@ -13,15 +13,31 @@ import messages from '../IdVerification.messages';
import exampleCard from '../assets/example-card.png';
function ReviewRequirementsPanel(props) {
const { userId, profileDataManager } = useContext(IdVerificationContext);
const {
userId, profileDataManager, setOptimizelyExperimentName,
} = useContext(IdVerificationContext);
const panelSlug = 'review-requirements';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const getExperiments = () => {
const {
experimentVariables: {
experimentName = '',
} = {},
} = window;
if (experimentName) {
setOptimizelyExperimentName(experimentName);
}
};
useEffect(() => {
sendTrackEvent('edx.id_verification.started', {
category: 'id_verification',
user_id: userId,
});
getExperiments();
}, [userId]);
function renderManagedProfileMessage() {

View File

@@ -1,18 +1,15 @@
import React, { useContext, useEffect } from 'react';
import React, { useEffect, useContext } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useRedirect } from '../../hooks';
import BasePanel from './BasePanel';
import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages';
import BasePanel from './BasePanel';
function SubmittedPanel(props) {
const { userId } = useContext(IdVerificationContext);
const { location: returnUrl, text: returnText } = useRedirect();
const panelSlug = 'submitted';
useEffect(() => {
@@ -32,10 +29,10 @@ function SubmittedPanel(props) {
</p>
<a
className="btn btn-primary"
href={`${getConfig().LMS_BASE_URL}/${returnUrl}`}
href={`${getConfig().LMS_BASE_URL}/dashboard`}
data-testid="return-button"
>
{props.intl.formatMessage(messages[returnText])}
{props.intl.formatMessage(messages['id.verification.return.dashboard'])}
</a>
</BasePanel>
);

View File

@@ -26,6 +26,7 @@ function SummaryPanel(props) {
nameOnAccount,
idPhotoName,
stopUserMedia,
optimizelyExperimentName,
setReachedSummary,
} = useContext(IdVerificationContext);
const nameToBeUsed = idPhotoName || nameOnAccount || '';
@@ -69,12 +70,9 @@ function SummaryPanel(props) {
};
if (idPhotoName) {
verificationData.idPhotoName = idPhotoName;
} else {
/**
* If learner has not entered an idPhotoName on the GetNameIdPanel,
* use the current nameOnAccount when submitting IDV.
*/
verificationData.idPhotoName = nameOnAccount;
}
if (optimizelyExperimentName) {
verificationData.optimizelyExperimentName = optimizelyExperimentName;
}
const result = await submitIdVerification(verificationData);
if (result.success) {
@@ -200,17 +198,17 @@ function SummaryPanel(props) {
</Link>
</div>
</div>
<CameraHelpWithUpload />
{!optimizelyExperimentName && <CameraHelpWithUpload />}
<div className="form-group">
<label htmlFor="name-to-be-used" className="font-weight-bold">
{props.intl.formatMessage(messages['id.verification.name.label'])}
{props.intl.formatMessage(messages['id.verification.account.name.label'])}
</label>
{renderManagedProfileMessage()}
<div className="d-flex">
<Input
id="name-to-be-used"
type="text"
disabled
readOnly
value={nameToBeUsed}
onChange={() => {}}
aria-describedby={profileDataManager ? 'profile-manager-warning' : null}
@@ -226,9 +224,9 @@ function SummaryPanel(props) {
<FormattedMessage
id="id.verification.account.name.edit"
defaultMessage="Edit {sr}"
description="Button to edit name, with clarifying information for screen readers."
description="Button to edit account name, with clarifying information for screen readers."
values={{
sr: <span className="sr-only">Name</span>,
sr: <span className="sr-only">Account Name</span>,
}}
/>
</Link>

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -17,31 +17,19 @@ import SupportedMediaTypes from '../SupportedMediaTypes';
function TakeIdPhotoPanel(props) {
const panelSlug = 'take-id-photo';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { setIdPhotoFile, idPhotoFile, useCameraForId } = useContext(IdVerificationContext);
const [mounted, setMounted] = useState(false);
useEffect(() => {
// This prevents focus switching to the heading when taking a photo
setMounted(true);
}, []);
const {
setIdPhotoFile, idPhotoFile, optimizelyExperimentName, shouldUseCamera,
} = useContext(IdVerificationContext);
return (
<BasePanel
name={panelSlug}
focusOnMount={!mounted}
title={useCameraForId
? props.intl.formatMessage(messages['id.verification.id.photo.title.camera'])
: props.intl.formatMessage(messages['id.verification.id.photo.title.upload'])}
title={shouldUseCamera ? props.intl.formatMessage(messages['id.verification.id.photo.title.camera']) : props.intl.formatMessage(messages['id.verification.id.photo.title.upload'])}
>
<div>
{idPhotoFile && !useCameraForId && (
<ImagePreview
src={idPhotoFile}
alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])}
/>
)}
{idPhotoFile && !shouldUseCamera && <ImagePreview src={idPhotoFile} alt={props.intl.formatMessage(messages['id.verification.id.photo.preview.alt'])} />}
{useCameraForId ? (
{shouldUseCamera ? (
<div>
<p>
{props.intl.formatMessage(messages['id.verification.id.photo.instructions.camera'])}
@@ -58,8 +46,8 @@ function TakeIdPhotoPanel(props) {
</div>
)}
</div>
{useCameraForId && <CameraHelp />}
<CollapsibleImageHelp />
{shouldUseCamera && !optimizelyExperimentName && <CameraHelp />}
<CollapsibleImageHelp isPortrait={false} />
<div className="action-row" style={{ visibility: idPhotoFile ? 'unset' : 'hidden' }}>
<Link to={nextPanelSlug} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}

View File

@@ -1,39 +1,53 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
import ImageFileUpload from '../ImageFileUpload';
import ImagePreview from '../ImagePreview';
import Camera from '../Camera';
import CameraHelp from '../CameraHelp';
import IdVerificationContext from '../IdVerificationContext';
import messages from '../IdVerification.messages';
import CollapsibleImageHelp from '../CollapsibleImageHelp';
import SupportedMediaTypes from '../SupportedMediaTypes';
function TakePortraitPhotoPanel(props) {
const panelSlug = 'take-portrait-photo';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { setFacePhotoFile, facePhotoFile } = useContext(IdVerificationContext);
const [mounted, setMounted] = useState(false);
useEffect(() => {
// This prevents focus switching to the heading when taking a photo
setMounted(true);
}, []);
const {
setFacePhotoFile, facePhotoFile, shouldUseCamera, optimizelyExperimentName,
} = useContext(IdVerificationContext);
return (
<BasePanel
name={panelSlug}
focusOnMount={!mounted}
title={props.intl.formatMessage(messages['id.verification.portrait.photo.title.camera'])}
title={shouldUseCamera ? props.intl.formatMessage(messages['id.verification.portrait.photo.title.camera']) : props.intl.formatMessage(messages['id.verification.portrait.photo.title.upload'])}
>
<div>
<p>
{props.intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])}
</p>
<Camera onImageCapture={setFacePhotoFile} isPortrait />
{facePhotoFile && !shouldUseCamera && <ImagePreview src={facePhotoFile} alt={props.intl.formatMessage(messages['id.verification.portrait.photo.preview.alt'])} />}
{shouldUseCamera ? (
<div>
<p>
{props.intl.formatMessage(messages['id.verification.portrait.photo.instructions.camera'])}
</p>
<Camera onImageCapture={setFacePhotoFile} isPortrait />
</div>
) : (
<div style={{ marginBottom: '1.25rem' }}>
<p data-testid="upload-text">
{props.intl.formatMessage(messages['id.verification.portrait.photo.instructions.upload'])}
<SupportedMediaTypes />
</p>
<ImageFileUpload onFileChange={setFacePhotoFile} intl={props.intl} />
</div>
)}
</div>
<CameraHelp isPortrait />
{shouldUseCamera && !optimizelyExperimentName && <CameraHelp isPortrait />}
<CollapsibleImageHelp isPortrait />
<div className="action-row" style={{ visibility: facePhotoFile ? 'unset' : 'hidden' }}>
<Link to={nextPanelSlug} className="btn btn-primary" data-testid="next-button">
{props.intl.formatMessage(messages['id.verification.next'])}

View File

@@ -1,5 +1,6 @@
import { useContext } from 'react';
import IdVerificationContext from './IdVerificationContext';
import { useLocation } from 'react-router-dom';
import IdVerificationContext, { MEDIA_ACCESS } from './IdVerificationContext';
const SLUGS = {
REVIEW_REQUIREMENTS: 'review-requirements',
@@ -16,6 +17,7 @@ const SLUGS = {
const panelSteps = [
SLUGS.REVIEW_REQUIREMENTS,
SLUGS.CHOOSE_MODE,
SLUGS.REQUEST_CAMERA_ACCESS,
SLUGS.PORTRAIT_PHOTO_CONTEXT,
SLUGS.TAKE_PORTRAIT_PHOTO,
@@ -29,7 +31,15 @@ const panelSteps = [
// eslint-disable-next-line import/prefer-default-export
export const useNextPanelSlug = (originSlug) => {
// Go back to the summary view if that's where they came from
const { reachedSummary } = useContext(IdVerificationContext);
const location = useLocation();
const isFromPortrait = location.state && location.state.fromPortraitCapture;
const isFromId = location.state && location.state.fromIdCapture;
const {
mediaAccess,
optimizelyExperimentName,
reachedSummary,
shouldUseCamera,
} = useContext(IdVerificationContext);
const canRerouteToSummary = [
SLUGS.TAKE_PORTRAIT_PHOTO,
@@ -41,6 +51,32 @@ export const useNextPanelSlug = (originSlug) => {
return SLUGS.SUMMARY;
}
// the following are used as part of an A/B experiment
if (isFromPortrait) {
if (mediaAccess === MEDIA_ACCESS.GRANTED) {
return SLUGS.PORTRAIT_PHOTO_CONTEXT;
}
return SLUGS.TAKE_PORTRAIT_PHOTO;
}
if (isFromId) {
if (mediaAccess === MEDIA_ACCESS.GRANTED) {
return SLUGS.ID_CONTEXT;
}
return SLUGS.TAKE_ID_PHOTO;
}
if (originSlug === SLUGS.REVIEW_REQUIREMENTS && !optimizelyExperimentName) {
return SLUGS.REQUEST_CAMERA_ACCESS;
}
if (originSlug === SLUGS.CHOOSE_MODE && !shouldUseCamera) {
return SLUGS.TAKE_PORTRAIT_PHOTO;
}
if (originSlug === SLUGS.TAKE_PORTRAIT_PHOTO && !shouldUseCamera) {
return SLUGS.TAKE_ID_PHOTO;
}
if (originSlug === SLUGS.REQUEST_CAMERA_ACCESS && mediaAccess !== MEDIA_ACCESS.GRANTED) {
return SLUGS.TAKE_PORTRAIT_PHOTO;
}
const nextIndex = panelSteps.indexOf(originSlug) + 1;
return nextIndex < panelSteps.length ? panelSteps[nextIndex] : null;
};
@@ -48,8 +84,11 @@ export const useNextPanelSlug = (originSlug) => {
// check if the user is too far into the flow and if so, return the slug of the
// furthest panel they are allow to be.
export const useVerificationRedirectSlug = (slug) => {
const { facePhotoFile, idPhotoFile } = useContext(IdVerificationContext);
const { facePhotoFile, idPhotoFile, optimizelyExperimentName } = useContext(IdVerificationContext);
const indexOfCurrentPanel = panelSteps.indexOf(slug);
if (!optimizelyExperimentName && slug === SLUGS.CHOOSE_MODE) {
return SLUGS.REVIEW_REQUIREMENTS;
}
if (!facePhotoFile) {
if (indexOfCurrentPanel > panelSteps.indexOf(SLUGS.TAKE_PORTRAIT_PHOTO)) {
return SLUGS.PORTRAIT_PHOTO_CONTEXT;

View File

@@ -4,6 +4,7 @@ import { createMemoryHistory } from 'history';
import {
render, cleanup, act, screen,
} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
@@ -67,7 +68,7 @@ describe('AccessBlocked', () => {
</Router>
)));
const text = screen.getByText(/We cannot verify your identity at this time./);
const text = screen.getByText(/You cannot verify your identity at this time./);
expect(text).toBeInTheDocument();
});

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import '@testing-library/jest-dom/extend-expect';
import {
render, cleanup, screen, act, fireEvent,
} from '@testing-library/react';

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import '@testing-library/jest-dom/extend-expect';
import {
render, cleanup, screen, act,
render, cleanup, screen, act, fireEvent,
} from '@testing-library/react';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import * as analytics from '@edx/frontend-platform/analytics';
@@ -22,18 +23,71 @@ const IntlCollapsible = injectIntl(CollapsibleImageHelp);
const history = createMemoryHistory();
describe('CollapsibleImageHelpPanel', () => {
const defaultProps = { intl: {} };
const defaultProps = {
intl: {},
isPortrait: true,
};
const contextValue = {
useCameraForId: true,
setUseCameraForId: jest.fn(),
shouldUseCamera: true,
setShouldUseCamera: jest.fn(),
optimizelyExperimentName: '',
mediaAccess: 'granted',
};
afterEach(() => {
cleanup();
});
it('does not return if not part of experiment', async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCollapsible {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const titleText = screen.queryByText('Upload a Photo Instead');
expect(titleText).not.toBeInTheDocument();
});
it('does not return if media access denied or unsupported', async () => {
let titleText = '';
contextValue.mediaAccess = 'denied';
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCollapsible {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
titleText = screen.queryByText('Upload a Photo Instead');
expect(titleText).not.toBeInTheDocument();
contextValue.mediaAccess = 'unsupported';
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCollapsible {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
titleText = screen.queryByText('Upload a Photo Instead');
expect(titleText).not.toBeInTheDocument();
});
it('shows the correct text if user should switch to upload', async () => {
contextValue.optimizelyExperimentName = 'test';
contextValue.mediaAccess = 'granted';
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -53,7 +107,9 @@ describe('CollapsibleImageHelpPanel', () => {
});
it('shows the correct text if user should switch to camera', async () => {
contextValue.useCameraForId = false;
contextValue.optimizelyExperimentName = 'test';
contextValue.mediaAccess = 'granted';
contextValue.shouldUseCamera = false;
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -71,4 +127,27 @@ describe('CollapsibleImageHelpPanel', () => {
const button = screen.getByTestId('toggle-button');
expect(button).toHaveTextContent('Switch to Camera Mode');
});
it('shows the correct text if user should switch to camera with pending media access', async () => {
contextValue.optimizelyExperimentName = 'test';
contextValue.mediaAccess = 'pending';
contextValue.shouldUseCamera = false;
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCollapsible {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const titleText = screen.getByText('Use Your Camera Instead');
expect(titleText).toBeInTheDocument();
const helpText = screen.getByTestId('help-text');
expect(helpText.textContent).toContain('If you are having trouble uploading a photo above');
const accessLink = screen.getByTestId('access-link');
fireEvent.click(accessLink);
expect(history.location.pathname).toEqual('/request-camera-access');
});
});

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { render, cleanup, act } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
@@ -8,16 +9,14 @@ import { getProfileDataManager } from '../../account-settings/data/service';
import { getExistingIdVerification, getEnrollments } from '../data/service';
import IdVerificationContextProvider from '../IdVerificationContextProvider';
import { VerifiedNameContext } from '../VerifiedNameContext';
jest.mock('../../account-settings/data/service', () => ({
getProfileDataManager: jest.fn(),
getVerifiedNameHistory: jest.fn(),
}));
jest.mock('../data/service', () => ({
getExistingIdVerification: jest.fn(() => ({})),
getEnrollments: jest.fn(() => ({})),
getExistingIdVerification: jest.fn(),
getEnrollments: jest.fn(() => []),
}));
describe('IdVerificationContextProvider', () => {
@@ -31,15 +30,12 @@ describe('IdVerificationContextProvider', () => {
});
it('renders correctly and calls getExistingIdVerification + getEnrollments', async () => {
const appContext = { authenticatedUser: { userId: 3, roles: [] } };
const verifiedNameContext = { verifiedName: '' };
const context = { authenticatedUser: { userId: 3, roles: [] } };
await act(async () => render((
<AppContext.Provider value={appContext}>
<VerifiedNameContext.Provider value={verifiedNameContext}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</VerifiedNameContext.Provider>
<AppContext.Provider value={context}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</AppContext.Provider>
)));
expect(getExistingIdVerification).toHaveBeenCalled();
@@ -47,26 +43,23 @@ describe('IdVerificationContextProvider', () => {
});
it('calls getProfileDataManager if the user has any roles', async () => {
const appContext = {
const context = {
authenticatedUser: {
userId: 3,
username: 'testname',
roles: ['enterprise_learner'],
},
};
const verifiedNameContext = { verifiedName: '' };
await act(async () => render((
<AppContext.Provider value={appContext}>
<VerifiedNameContext.Provider value={verifiedNameContext}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</VerifiedNameContext.Provider>
<AppContext.Provider value={context}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</AppContext.Provider>
)));
expect(getProfileDataManager).toHaveBeenCalledWith(
appContext.authenticatedUser.username,
appContext.authenticatedUser.roles,
context.authenticatedUser.username,
context.authenticatedUser.roles,
);
});
});

View File

@@ -11,13 +11,6 @@ import * as selectors from '../data/selectors';
jest.mock('../data/selectors', () => jest.fn().mockImplementation(() => ({ idVerificationSelector: () => ({}) })));
jest.mock('../IdVerificationContextProvider', () => jest.fn(({ children }) => children));
jest.mock('../VerifiedNameContext', () => {
const originalModule = jest.requireActual('../VerifiedNameContext');
return {
...originalModule,
VerifiedNameContextProvider: jest.fn(({ children }) => children),
};
});
jest.mock('../panels/ReviewRequirementsPanel');
jest.mock('../panels/RequestCameraAccessPanel');
jest.mock('../panels/PortraitPhotoContextPanel');
@@ -41,8 +34,36 @@ describe('IdVerificationPage', () => {
intl: {},
};
it('does not store irrelevant query params', async () => {
history.push('/?test=irrelevant');
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<Provider store={store}>
<IntlIdVerificationPage {...props} />
</Provider>
</IntlProvider>
</Router>
)));
expect(sessionStorage.setItem).toHaveBeenCalledTimes(0);
});
it('does not store empty course_id', async () => {
history.push('/?course_id=');
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<Provider store={store}>
<IntlIdVerificationPage {...props} />
</Provider>
</IntlProvider>
</Router>
)));
expect(sessionStorage.setItem).toHaveBeenCalledTimes(0);
});
it('decodes and stores course_id', async () => {
history.push(`/?course_id=${encodeURIComponent('course-v1:edX+DemoX+Demo_Course')}`);
history.push('/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course');
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -53,25 +74,8 @@ describe('IdVerificationPage', () => {
</Router>
)));
expect(sessionStorage.setItem).toHaveBeenCalledWith(
'courseId',
'courseRunKey',
'course-v1:edX+DemoX+Demo_Course',
);
});
it('stores `next` value', async () => {
history.push('/?next=dashboard');
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<Provider store={store}>
<IntlIdVerificationPage {...props} />
</Provider>
</IntlProvider>
</Router>
)));
expect(sessionStorage.setItem).toHaveBeenCalledWith(
'next',
'dashboard',
);
});
});

View File

@@ -1,59 +0,0 @@
import React, { useContext } from 'react';
import { render, cleanup, waitFor } from '@testing-library/react';
import { getVerifiedNameHistory } from '../../account-settings/data/service';
import { VerifiedNameContext, VerifiedNameContextProvider } from '../VerifiedNameContext';
const VerifiedNameContextTestComponent = () => {
const { verifiedName } = useContext(VerifiedNameContext);
return (
<>
{verifiedName && (<div data-testid="verified-name">{verifiedName}</div>)}
</>
);
};
jest.mock('../../account-settings/data/service', () => ({
getVerifiedNameHistory: jest.fn(() => ({})),
}));
describe('VerifiedNameContextProvider', () => {
const defaultProps = {
children: <div />,
intl: {},
};
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
it('calls getVerifiedNameHistory', async () => {
jest.mock('../../account-settings/data/service', () => ({
getVerifiedNameHistory: jest.fn(() => ({})),
}));
render(<VerifiedNameContextProvider {...defaultProps} />);
await waitFor(() => expect(getVerifiedNameHistory).toHaveBeenCalledTimes(1));
});
it('sets verifiedName', async () => {
const mockReturnValue = {
results: [{
verified_name: 'Michael',
status: 'approved',
created: '2021-08-31T18:33:32.489200Z',
}],
};
getVerifiedNameHistory.mockReturnValueOnce(mockReturnValue);
const { getByTestId } = render((
<VerifiedNameContextProvider {...defaultProps}>
<VerifiedNameContextTestComponent />
</VerifiedNameContextProvider>
));
await waitFor(() => expect(getVerifiedNameHistory).toHaveBeenCalledTimes(1));
expect(getByTestId('verified-name')).toHaveTextContent('Michael');
});
});

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import ChooseModePanel from '../../panels/ChooseModePanel';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
const IntlChooseModePanel = injectIntl(ChooseModePanel);
const history = createMemoryHistory();
describe('ChooseModePanel', () => {
const defaultProps = {
intl: {},
};
const contextValue = {
optimizelyExperimentName: 'test',
shouldUseCamera: false,
reachedSummary: false,
};
afterEach(() => {
cleanup();
});
it('renders correctly', async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlChooseModePanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
// check that radio button for upload is selected
const uploadRadioButton = await screen.findByLabelText('Upload photos from my device');
expect(uploadRadioButton).toBeChecked();
// check that if upload is selected, next button goes to correct screen
const nextButton = await screen.findByTestId('next-button');
expect(nextButton.getAttribute('href')).toEqual('/take-portrait-photo');
});
it('renders correctly if user wants to use camera', async () => {
contextValue.shouldUseCamera = true;
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlChooseModePanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
// check that radio button for camera is selected
const cameraRadioButton = await screen.findByLabelText('Take pictures using my camera');
expect(cameraRadioButton).toBeChecked();
// check that if upload is selected, next button goes to correct screen
const nextButton = await screen.findByTestId('next-button');
expect(nextButton.getAttribute('href')).toEqual('/request-camera-access');
});
it('reroutes correctly if reachedSummary is true', async () => {
contextValue.shouldUseCamera = true;
contextValue.reachedSummary = true;
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlChooseModePanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const nextButton = await screen.findByTestId('next-button');
fireEvent.click(nextButton);
expect(history.location.pathname).toEqual('/request-camera-access');
});
it('redirects if user is not part of experiment', async () => {
contextValue.optimizelyExperimentName = '';
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlChooseModePanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
// check that radio button is not in document
const cameraRadioButton = await screen.queryByLabelText('Take pictures using my camera');
expect(cameraRadioButton).not.toBeInTheDocument();
});
});

View File

@@ -7,7 +7,6 @@ import {
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import { VerifiedNameContext } from '../../VerifiedNameContext';
import GetNameIdPanel from '../../panels/GetNameIdPanel';
jest.mock('@edx/frontend-platform/analytics', () => ({
@@ -23,7 +22,7 @@ describe('GetNameIdPanel', () => {
intl: {},
};
const IDVerificationContextValue = {
const contextValue = {
nameOnAccount: 'test',
userId: 3,
idPhotoName: '',
@@ -32,17 +31,13 @@ describe('GetNameIdPanel', () => {
idPhotoFile: 'test.jpg',
};
const verifiedNameContextValue = {};
const getPanel = async (idVerificationContextValue = IDVerificationContextValue) => {
const getPanel = async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
<IdVerificationContext.Provider value={idVerificationContextValue}>
<IntlGetNameIdPanel {...defaultProps} />
</IdVerificationContext.Provider>
</VerifiedNameContext.Provider>
<IdVerificationContext.Provider value={contextValue}>
<IntlGetNameIdPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
@@ -52,28 +47,56 @@ describe('GetNameIdPanel', () => {
cleanup();
});
it('shows feedback message when user has an empty name', async () => {
await getPanel();
// Ensure the feedback message on name shows when the user has an empty name
expect(await screen.queryByTestId('id-name-feedback-message')).toBeTruthy();
});
it('does not show feedback message when user has an non-empty name', async () => {
const idVerificationContextValue = {
...IDVerificationContextValue,
idPhotoName: 'test',
};
await getPanel(idVerificationContextValue);
// Ensure the feedback message on name shows when the user has an empty name
expect(await screen.queryByTestId('id-name-feedback-message')).toBeNull();
});
it('calls setIdPhotoName with correct name', async () => {
it('edits', async () => {
await getPanel();
const yesButton = await screen.findByTestId('name-matches-yes');
const noButton = await screen.findByTestId('name-matches-no');
const input = await screen.findByTestId('name-input');
fireEvent.change(input, { target: { value: 'test' } });
expect(IDVerificationContextValue.setIdPhotoName).toHaveBeenCalledWith('test');
const nextButton = await screen.findByTestId('next-button');
expect(input).toHaveProperty('readOnly');
fireEvent.click(noButton);
expect(input).toHaveProperty('readOnly', false);
expect(nextButton.classList.contains('disabled')).toBe(true);
fireEvent.change(input, { target: { value: 'test change' } });
expect(contextValue.setIdPhotoName).toHaveBeenCalled();
fireEvent.click(yesButton);
expect(input).toHaveProperty('readOnly');
expect(contextValue.setIdPhotoName).toHaveBeenCalled();
});
it('disables radio buttons + next button and enables input if account name is blank', async () => {
contextValue.nameOnAccount = '';
await getPanel();
const yesButton = await screen.findByTestId('name-matches-yes');
const noButton = await screen.findByTestId('name-matches-no');
const input = await screen.findByTestId('name-input');
const nextButton = await screen.findByTestId('next-button');
expect(yesButton).toHaveProperty('disabled');
expect(noButton).toHaveProperty('disabled');
expect(input).toHaveProperty('readOnly', false);
expect(nextButton.classList.contains('disabled')).toBe(true);
});
it('blocks the user from changing account name if managed by a third party', async () => {
contextValue.profileDataManager = 'test-org';
await getPanel();
const noButton = await screen.findByTestId('name-matches-no');
const input = await screen.findByTestId('name-input');
const nextButton = await screen.findByTestId('next-button');
fireEvent.click(noButton);
expect(input).toHaveProperty('readOnly');
expect(nextButton.classList.contains('disabled')).toBe(true);
const warning = await screen.getAllByText('test-org');
expect(warning.length).toEqual(1);
});
it('routes to SummaryPanel', async () => {

View File

@@ -4,6 +4,7 @@ import { createMemoryHistory } from 'history';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
@@ -23,6 +24,7 @@ describe('IdContextPanel', () => {
};
const contextValue = {
optimizelyExperimentName: '',
facePhotoFile: 'test.jpg',
reachedSummary: false,
};
@@ -61,4 +63,33 @@ describe('IdContextPanel', () => {
fireEvent.click(button);
expect(history.location.pathname).toEqual('/take-id-photo');
});
it('does not show help text for photo upload if not part of experiment', async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlIdContextPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const title = await screen.queryByText('What if I want to upload a photo instead?');
expect(title).not.toBeInTheDocument();
});
it('shows help text for photo upload if part of experiment', async () => {
contextValue.optimizelyExperimentName = 'test';
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlIdContextPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const title = await screen.queryByText('What if I want to upload a photo instead?');
expect(title).toBeInTheDocument();
});
});

View File

@@ -4,6 +4,7 @@ import { createMemoryHistory } from 'history';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import '@edx/frontend-platform/analytics';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import PortraitPhotoContextPanel from '../../panels/PortraitPhotoContextPanel';
@@ -22,7 +23,10 @@ describe('PortraitPhotoContextPanel', () => {
intl: {},
};
const contextValue = { reachedSummary: false };
const contextValue = {
optimizelyExperimentName: '',
reachedSummary: false,
};
afterEach(() => {
cleanup();
@@ -58,4 +62,33 @@ describe('PortraitPhotoContextPanel', () => {
fireEvent.click(button);
expect(history.location.pathname).toEqual('/take-portrait-photo');
});
it('does not show help text for photo upload if not part of experiment', async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlPortraitPhotoContextPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const title = await screen.queryByText('What if I want to upload a photo instead?');
expect(title).not.toBeInTheDocument();
});
it('shows help text for photo upload if part of experiment', async () => {
contextValue.optimizelyExperimentName = 'test';
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlPortraitPhotoContextPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const title = await screen.queryByText('What if I want to upload a photo instead?');
expect(title).toBeInTheDocument();
});
});

View File

@@ -5,7 +5,7 @@ import { createMemoryHistory } from 'history';
import {
render, screen, cleanup, act, fireEvent,
} from '@testing-library/react';
import { getConfig } from '@edx/frontend-platform';
import '@testing-library/jest-dom/extend-expect';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import RequestCameraAccessPanel from '../../panels/RequestCameraAccessPanel';
@@ -85,6 +85,27 @@ describe('RequestCameraAccessPanel', () => {
expect(text).toHaveTextContent(/It looks like we're unable to access your camera./);
});
it('renders correctly with media access denied in optimizely experiment', async () => {
contextValue.mediaAccess = 'denied';
contextValue.optimizelyExperimentName = 'test';
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const text = await screen.findByTestId('camera-access-failure');
expect(text).toHaveTextContent(/It looks like we're unable to access your camera./);
const nextButton = await screen.findByText('Continue with Upload');
fireEvent.click(nextButton);
expect(history.location.pathname).toEqual('/take-portrait-photo');
contextValue.optimizelyExperimentName = '';
});
it('renders correctly with media access unsupported with Chrome browser', async () => {
contextValue.mediaAccess = 'unsupported';
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: 'Chrome' } });
@@ -183,25 +204,9 @@ describe('RequestCameraAccessPanel', () => {
expect(text).toHaveTextContent(/Open the Flash Player/);
});
it('routes to dashboard when camera access is denied', async () => {
contextValue.mediaAccess = 'denied';
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByRole('link');
expect(button).toHaveAttribute('href', `${getConfig().LMS_BASE_URL}/dashboard`);
});
it('routes correctly to portrait context', async () => {
it('reroutes correctly to portrait context', async () => {
contextValue.mediaAccess = 'granted';
history.location.state = { fromPortraitCapture: true };
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
@@ -217,4 +222,105 @@ describe('RequestCameraAccessPanel', () => {
fireEvent.click(button);
expect(history.location.pathname).toEqual('/portrait-photo-context');
});
it('reroutes correctly to ID context', async () => {
contextValue.mediaAccess = 'granted';
history.location.state = { fromIdCapture: true };
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
expect(history.location.pathname).toEqual('/id-context');
});
it('reroutes to portrait context when reachedSummary is true', async () => {
contextValue.mediaAccess = 'granted';
contextValue.reachedSummary = true;
history.location.state = { fromPortraitCapture: true };
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
expect(history.location.pathname).toEqual('/portrait-photo-context');
});
it('reroutes to ID context when reachedSummary is true', async () => {
contextValue.mediaAccess = 'granted';
contextValue.reachedSummary = true;
history.location.state = { fromIdCapture: true };
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
expect(history.location.pathname).toEqual('/id-context');
});
it('reroutes correctly to portrait context with no media access', async () => {
contextValue.mediaAccess = 'denied';
contextValue.optimizelyExperimentName = 'test';
history.location.state = { fromPortraitCapture: true };
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
expect(history.location.pathname).toEqual('/take-portrait-photo');
contextValue.optimizelyExperimentName = '';
});
it('reroutes correctly to ID context with no media access', async () => {
contextValue.mediaAccess = 'denied';
contextValue.optimizelyExperimentName = 'test';
history.location.state = { fromIdCapture: true };
Bowser.parse = jest.fn().mockReturnValue({ browser: { name: '' } });
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlRequestCameraAccessPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
expect(history.location.pathname).toEqual('/take-id-photo');
contextValue.optimizelyExperimentName = '';
});
});

View File

@@ -22,7 +22,7 @@ describe('ReviewRequirementsPanel', () => {
intl: {},
};
const context = {};
const context = { setOptimizelyExperimentName: jest.fn() };
const getPanel = async () => {
await act(async () => render((
@@ -47,6 +47,13 @@ describe('ReviewRequirementsPanel', () => {
expect(history.location.pathname).toEqual('/request-camera-access');
});
it('updates optimizely experiment name in context', async () => {
window.experimentVariables = {};
window.experimentVariables.experimentName = 'test-experiment';
await getPanel();
expect(context.setOptimizelyExperimentName).toHaveBeenCalledWith('test-experiment');
});
it('displays an alert if the user\'s account information is managed by a third party', async () => {
context.profileDataManager = 'test-org';
await getPanel();

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import '@testing-library/jest-dom/extend-expect';
import {
render, cleanup, act, screen,
} from '@testing-library/react';
@@ -28,20 +29,14 @@ describe('SubmittedPanel', () => {
};
beforeEach(() => {
const mockStorage = {};
global.Storage.prototype.setItem = jest.fn((key, value) => {
mockStorage[key] = value;
});
global.Storage.prototype.getItem = jest.fn(key => mockStorage[key]);
global.sessionStorage.getItem = jest.fn();
});
afterEach(() => {
global.Storage.prototype.setItem.mockReset();
global.Storage.prototype.getItem.mockReset();
cleanup();
});
it('links to dashboard without courseId or next value', async () => {
it('links to dashboard without courseRunKey', async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -53,38 +48,5 @@ describe('SubmittedPanel', () => {
)));
const button = await screen.findByTestId('return-button');
expect(button).toHaveTextContent(/Return to Your Dashboard/);
expect(button).toHaveAttribute('href', `${process.env.LMS_BASE_URL}/dashboard`);
});
it('links to course when courseId is stored', async () => {
sessionStorage.setItem('courseId', 'course-v1:edX+DemoX+Demo_Course');
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlSubmittedPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('return-button');
expect(button).toHaveTextContent(/Return to Course/);
expect(button).toHaveAttribute('href', `${process.env.LMS_BASE_URL}/courses/course-v1:edX+DemoX+Demo_Course`);
});
it('links to specified page when `next` value is provided', async () => {
sessionStorage.setItem('next', 'some_page');
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlSubmittedPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('return-button');
expect(button).toHaveTextContent(/Return/);
expect(button).toHaveAttribute('href', `${process.env.LMS_BASE_URL}/some_page`);
});
});

View File

@@ -5,11 +5,11 @@ import {
render, cleanup, act, screen, fireEvent, waitFor,
} from '@testing-library/react';
import '@edx/frontend-platform/analytics';
import '@testing-library/jest-dom/extend-expect';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import * as dataService from '../../data/service';
import IdVerificationContext from '../../IdVerificationContext';
import SummaryPanel from '../../panels/SummaryPanel';
import { VerifiedNameContext } from '../../VerifiedNameContext';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
@@ -27,26 +27,23 @@ describe('SummaryPanel', () => {
intl: {},
};
const appContextValue = {
const contextValue = {
facePhotoFile: 'test.jpg',
idPhotoFile: 'test.jpg',
nameOnAccount: 'test name',
idPhotoName: 'test name',
optimizelyExperimentName: 'test-experiment',
stopUserMedia: jest.fn(),
setReachedSummary: jest.fn(),
};
const verifiedNameContextValue = {};
const getPanel = async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<VerifiedNameContext.Provider value={verifiedNameContextValue}>
<IdVerificationContext.Provider value={appContextValue}>
<IntlSummaryPanel {...defaultProps} />
</IdVerificationContext.Provider>
</VerifiedNameContext.Provider>
<IdVerificationContext.Provider value={contextValue}>
<IntlSummaryPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
@@ -73,15 +70,17 @@ describe('SummaryPanel', () => {
});
it('allows user to upload ID photo', async () => {
contextValue.optimizelyExperimentName = '';
await getPanel();
const collapsible = await screen.getAllByRole('button', { 'aria-expanded': false })[0];
fireEvent.click(collapsible);
const uploadButton = await screen.getByTestId('fileUpload');
expect(uploadButton).toBeVisible();
contextValue.optimizelyExperimentName = 'test-experiment';
});
it('displays warning if account is managed by a third party', async () => {
appContextValue.profileDataManager = 'test-org';
contextValue.profileDataManager = 'test-org';
await getPanel();
const warning = await screen.getAllByText('test-org');
expect(warning.length).toEqual(1);
@@ -89,25 +88,26 @@ describe('SummaryPanel', () => {
it('submits', async () => {
const verificationData = {
facePhotoFile: appContextValue.facePhotoFile,
idPhotoFile: appContextValue.idPhotoFile,
idPhotoName: appContextValue.idPhotoName,
facePhotoFile: contextValue.facePhotoFile,
idPhotoFile: contextValue.idPhotoFile,
idPhotoName: contextValue.idPhotoName,
optimizelyExperimentName: contextValue.optimizelyExperimentName,
courseRunKey: null,
};
await getPanel();
const button = await screen.findByTestId('submit-button');
fireEvent.click(button);
expect(dataService.submitIdVerification).toHaveBeenCalledWith(verificationData);
await waitFor(() => expect(appContextValue.stopUserMedia).toHaveBeenCalled());
await waitFor(() => expect(contextValue.stopUserMedia).toHaveBeenCalled());
});
it('submits a name if name is blank', async () => {
appContextValue.idPhotoName = '';
it('does not submit a name if name is blank', async () => {
contextValue.idPhotoName = '';
const verificationData = {
facePhotoFile: appContextValue.facePhotoFile,
idPhotoFile: appContextValue.idPhotoFile,
facePhotoFile: contextValue.facePhotoFile,
idPhotoFile: contextValue.idPhotoFile,
optimizelyExperimentName: contextValue.optimizelyExperimentName,
courseRunKey: null,
idPhotoName: appContextValue.nameOnAccount,
};
await getPanel();
const button = await screen.findByTestId('submit-button');
@@ -115,13 +115,13 @@ describe('SummaryPanel', () => {
expect(dataService.submitIdVerification).toHaveBeenCalledWith(verificationData);
});
it('submits a name if a name is unchanged', async () => {
appContextValue.idPhotoName = null;
it('does not submit a name if name is unchanged', async () => {
contextValue.idPhotoName = null;
const verificationData = {
facePhotoFile: appContextValue.facePhotoFile,
idPhotoFile: appContextValue.idPhotoFile,
facePhotoFile: contextValue.facePhotoFile,
idPhotoFile: contextValue.idPhotoFile,
optimizelyExperimentName: contextValue.optimizelyExperimentName,
courseRunKey: null,
idPhotoName: appContextValue.nameOnAccount,
};
await getPanel();
const button = await screen.findByTestId('submit-button');
@@ -194,4 +194,11 @@ describe('SummaryPanel', () => {
'One or more of the files you have uploaded is in an unsupported format. Please choose from the following:',
);
});
it('does not show ID upload option if user is in experiment', async () => {
await getPanel();
const collapsible = await screen.queryByTestId('collapsible');
expect(collapsible).not.toBeInTheDocument();
contextValue.optimizelyExperimentName = 'test-experiment';
});
});

View File

@@ -4,6 +4,7 @@ import { createMemoryHistory } from 'history';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import TakeIdPhotoPanel from '../../panels/TakeIdPhotoPanel';
@@ -28,7 +29,6 @@ describe('TakeIdPhotoPanel', () => {
idPhotoFile: null,
reachedSummary: false,
setIdPhotoFile: jest.fn(),
useCameraForId: false,
};
afterEach(() => {
@@ -84,6 +84,9 @@ describe('TakeIdPhotoPanel', () => {
});
it('shows correct text if user should use upload', async () => {
contextValue.optimizelyExperimentName = 'test';
contextValue.shouldUseCamera = false;
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -95,10 +98,10 @@ describe('TakeIdPhotoPanel', () => {
)));
// check that upload title and text are correct
const title = await screen.findByText('Upload a Photo of Your Identification Card');
const title = await screen.findByText('Upload a Photo of Your ID');
expect(title).toBeVisible();
const text = await screen.findByTestId('upload-text');
expect(text.textContent).toContain('Please upload a photo of your identification card');
expect(text.textContent).toContain('Please upload an ID photo');
});
});

View File

@@ -4,6 +4,7 @@ import { createMemoryHistory } from 'history';
import {
render, cleanup, act, screen, fireEvent,
} from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import IdVerificationContext from '../../IdVerificationContext';
import TakePortraitPhotoPanel from '../../panels/TakePortraitPhotoPanel';
@@ -28,6 +29,7 @@ describe('TakePortraitPhotoPanel', () => {
idPhotoFile: null,
reachedSummary: false,
setFacePhotoFile: jest.fn(),
setShouldUseCamera: jest.fn(),
};
afterEach(() => {
@@ -50,6 +52,7 @@ describe('TakePortraitPhotoPanel', () => {
it('shows next button after photo is taken and routes to IdContextPanel', async () => {
contextValue.facePhotoFile = 'test.jpg';
contextValue.shouldUseCamera = true;
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -82,4 +85,26 @@ describe('TakePortraitPhotoPanel', () => {
fireEvent.click(button);
expect(history.location.pathname).toEqual('/summary');
});
it('shows correct text if user should use upload', async () => {
contextValue.optimizelyExperimentName = 'test';
contextValue.shouldUseCamera = false;
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlTakePortraitPhotoPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
// check that upload title and text are correct
const title = await screen.findByText('Upload a Photo of Yourself');
expect(title).toBeVisible();
const text = await screen.findByTestId('upload-text');
expect(text.textContent).toContain('Please upload a portrait photo');
});
});

View File

@@ -1,6 +1,4 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import 'babel-polyfill';
import 'formdata-polyfill';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import {
@@ -20,6 +18,7 @@ import CoachingConsent from './account-settings/coaching/CoachingConsent';
import appMessages from './i18n';
import './index.scss';
import './assets/favicon.ico';
subscribe(APP_READY, () => {
ReactDOM.render(
@@ -63,8 +62,6 @@ initialize({
COACHING_ENABLED: (process.env.COACHING_ENABLED || false),
ENABLE_DEMOGRAPHICS_COLLECTION: (process.env.ENABLE_DEMOGRAPHICS_COLLECTION || false),
DEMOGRAPHICS_BASE_URL: process.env.DEMOGRAPHICS_BASE_URL,
ENABLE_COPPA_COMPLIANCE: (process.env.ENABLE_COPPA_COMPLIANCE || false),
MARKETING_EMAILS_OPT_IN: (process.env.MARKETING_EMAILS_OPT_IN || false),
}, 'App loadConfig override handler');
},
},

View File

@@ -1,6 +1,4 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
/* eslint-disable import/no-extraneous-dependencies */
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

View File

@@ -1,49 +0,0 @@
import PropTypes from 'prop-types';
import { render, waitFor } from '@testing-library/react';
import { useAsyncCall } from '../hooks';
import { LOADING_STATUS, SUCCESS_STATUS, FAILURE_STATUS } from '../constants';
const TestUseAsyncCallHookComponent = ({ asyncFunc }) => {
const { status, data } = useAsyncCall(asyncFunc);
return (
<>
<div>{status}</div>
{data && Object.keys(data).length !== 0 && <div data-testid="data">{ data.data }</div>}
</>
);
};
TestUseAsyncCallHookComponent.propTypes = {
asyncFunc: PropTypes.func.isRequired,
};
describe('useAsyncCall mock', () => {
it('returns status and data correctly for successful response', async () => {
const mockAsyncFunc = jest.fn(async () => ({ data: 'data' }));
const { queryByText } = render(<TestUseAsyncCallHookComponent asyncFunc={mockAsyncFunc} />);
await waitFor(() => (expect(mockAsyncFunc).toHaveBeenCalledTimes(1)));
expect(queryByText(SUCCESS_STATUS)).not.toBeNull();
expect(queryByText('data')).not.toBeNull();
});
it('returns status and data correctly for unsuccessful response', async () => {
const mockAsyncFunc = jest.fn(async () => ({}));
const { queryByText, queryByTestId } = render(<TestUseAsyncCallHookComponent asyncFunc={mockAsyncFunc} />);
await waitFor(() => (expect(mockAsyncFunc).toHaveBeenCalledTimes(1)));
expect(queryByText(FAILURE_STATUS)).not.toBeNull();
expect(queryByTestId('data')).toBeNull();
});
it('returns status and data correctly for pending request', async () => {
const mockAsyncFunc = jest.fn(async () => ({}));
const { queryByText, queryByTestId } = render(<TestUseAsyncCallHookComponent asyncFunc={mockAsyncFunc} />);
expect(queryByText(LOADING_STATUS)).not.toBeNull();
expect(queryByTestId('data')).toBeNull();
await waitFor(() => (expect(mockAsyncFunc).toHaveBeenCalledTimes(1)));
});
});

View File

@@ -1,83 +0,0 @@
import { compareVerifiedNamesByCreatedDate, getMostRecentApprovedOrPendingVerifiedName } from '../utils';
describe('getMostRecentApprovedOrPendingVerifiedName', () => {
it('returns correct verified name if one exists', () => {
const verifiedNames = [
{
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
},
{
created: '2021-09-03T18:33:32.489200Z',
verified_name: 'Michelangelo',
status: 'approved',
},
];
expect(getMostRecentApprovedOrPendingVerifiedName(verifiedNames)).toEqual(verifiedNames[1].verified_name);
});
it('returns no verified name if one does not exist', () => {
const verifiedNames = [
{
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
},
{
created: '2021-09-03T18:33:32.489200Z',
verified_name: 'Michelangelo',
status: 'submitted',
},
];
expect(getMostRecentApprovedOrPendingVerifiedName(verifiedNames)).toBeNull();
});
});
describe('compareVerifiedNamesByCreatedDate', () => {
it('returns 0 when equal', () => {
const a = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
};
const b = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Michael',
status: 'denied',
};
expect(compareVerifiedNamesByCreatedDate(a, b)).toEqual(0);
});
it('returns negative number when first argument is greater than second argument', () => {
const a = {
created: '2021-09-30T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
};
const b = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Michael',
status: 'denied',
};
expect(compareVerifiedNamesByCreatedDate(a, b)).toBeLessThan(0);
});
it('returns positive number when first argument is less than second argument', () => {
const a = {
created: '2021-08-31T18:33:32.489200Z',
verified_name: 'Mike',
status: 'denied',
};
const b = {
created: '2021-09-30T18:33:32.489200Z',
verified_name: 'Michael',
status: 'denied',
};
expect(compareVerifiedNamesByCreatedDate(a, b)).toBeGreaterThan(0);
});
});

View File

@@ -1,40 +0,0 @@
/**
* Compare two dates.
* @param {*} a the first date
* @param {*} b the second date
* @returns a negative integer if a > b, a positive integer if a < b, or 0 if a = b
*/
export function compareVerifiedNamesByCreatedDate(a, b) {
const aTimeSinceEpoch = new Date(a.created).getTime();
const bTimeSinceEpoch = new Date(b.created).getTime();
return bTimeSinceEpoch - aTimeSinceEpoch;
}
/**
*
* @param {*} verifiedNames a list of verified name objects, where each object has at least the
* following keys: created, status, and verified_name.
* @returns the most recent verified name object from the list parameter with the 'pending' or
* 'accepted' status, if one exists; otherwise, null
*/
export function getMostRecentApprovedOrPendingVerifiedName(verifiedNames) {
// clone array so as not to modify original array
const names = [...verifiedNames];
if (Array.isArray(names)) {
names.sort(compareVerifiedNamesByCreatedDate);
}
// We only want to consider a subset of verified names when determining the value of nameOnAccount.
// approved: consider this status, as the name has been verified by IDV and should supersede the full name
// (profile name).
// pending: consider this status, as the learner has started the name change process through the
// Account Settings page, and has been navigated to IDV to complete the name change process.
// submitted: do not consider this status, as the name has already been submitted for verification through
// IDV but has not yet been verified
// denied: do not consider this status because the name was already denied via the IDV process
const applicableNames = names.filter(name => ['approved', 'pending'].includes(name.status));
const applicableName = applicableNames.length > 0 ? applicableNames[0].verified_name : null;
return applicableName;
}