diff --git a/src/account-settings/AccountSettingsPage.jsx b/src/account-settings/AccountSettingsPage.jsx index 579daa4..6fc4279 100644 --- a/src/account-settings/AccountSettingsPage.jsx +++ b/src/account-settings/AccountSettingsPage.jsx @@ -31,6 +31,7 @@ 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'; @@ -373,6 +374,17 @@ class AccountSettingsPage extends React.Component { return this.props.intl.formatMessage(messages['account.settings.static.field.empty.no.admin']); } + renderNameChangeModal() { + const shouldDisplayNameChangeModal = ( + this.props.formErrors.name + && this.props.formErrors.name.includes('ID verification') + ); + if (shouldDisplayNameChangeModal) { + return ; + } + return null; + } + renderSecondaryEmailField(editableFieldProps) { if (!this.props.formValues.secondary_email_enabled) { return null; @@ -440,6 +452,8 @@ class AccountSettingsPage extends React.Component {

{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}

{this.renderManagedProfileMessage()} + {this.renderNameChangeModal()} + { 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: diff --git a/src/account-settings/data/sagas.js b/src/account-settings/data/sagas.js index 95d77c9..b79f9b9 100644 --- a/src/account-settings/data/sagas.js +++ b/src/account-settings/data/sagas.js @@ -30,6 +30,7 @@ import { // 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, @@ -152,6 +153,7 @@ export default function* saga() { deleteAccountSaga(), siteLanguageSaga(), resetPasswordSaga(), + nameChangeSaga(), thirdPartyAuthSaga(), ]); } diff --git a/src/account-settings/data/selectors.js b/src/account-settings/data/selectors.js index 2dd9fc0..0d29a93 100644 --- a/src/account-settings/data/selectors.js +++ b/src/account-settings/data/selectors.js @@ -211,6 +211,7 @@ export const accountSettingsPageSelector = createSelector( formValuesSelector, valuesSelector, draftsSelector, + errorSelector, profileDataManagerSelector, staticFieldsSelector, timeZonesSelector, @@ -223,6 +224,7 @@ export const accountSettingsPageSelector = createSelector( formValues, committedValues, drafts, + formErrors, profileDataManager, staticFields, timeZoneOptions, @@ -240,6 +242,7 @@ export const accountSettingsPageSelector = createSelector( formValues, committedValues, drafts, + formErrors, profileDataManager, staticFields, tpaProviders: accountSettings.thirdPartyAuth.providers, @@ -311,3 +314,12 @@ export const demographicsSectionSelector = createSelector( formErrors: errors, }), ); + +export const nameChangeSelector = createSelector( + accountSettingsSelector, + formValuesSelector, + (accountSettings, formValues) => ({ + ...accountSettings.nameChange, + formValues, + }), +); diff --git a/src/account-settings/data/service.js b/src/account-settings/data/service.js index 1422406..099b83a 100644 --- a/src/account-settings/data/service.js +++ b/src/account-settings/data/service.js @@ -216,6 +216,15 @@ export async function getVerifiedNameHistory() { 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 diff --git a/src/account-settings/name-change/NameChange.jsx b/src/account-settings/name-change/NameChange.jsx new file mode 100644 index 0000000..401e64a --- /dev/null +++ b/src/account-settings/name-change/NameChange.jsx @@ -0,0 +1,197 @@ +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({ + errors, + formValues, + intl, + saveState, +}) { + const dispatch = useDispatch(); + const { push } = useHistory(); + const { username } = getAuthenticatedUser(); + const [verifiedNameInput, setVerifiedNameInput] = useState(''); + const [confirmedWarning, setConfirmedWarning] = useState(false); + + function resetLocalState() { + setConfirmedWarning(false); + dispatch(requestNameChangeReset()); + } + + function handleChange(e) { + setVerifiedNameInput(e.target.value); + } + + function handleClose() { + resetLocalState(); + dispatch(closeForm('name')); + 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 { + dispatch(requestNameChange(username, formValues.name, verifiedNameInput)); + } + } + + useEffect(() => { + if (saveState === 'complete') { + handleClose(); + push('/id-verification'); + } + }, [saveState]); + + function renderErrors() { + if (Object.keys(errors).length > 0) { + return ( + <> + {Object.entries(errors).map(([key, value]) => ( + + { + key === 'general_error' + ? intl.formatMessage(messages['account.settings.name.change.error.general']) + : value + } + + ))} + + ); + } + 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 ( + +

+ {intl.formatMessage(messages['account.settings.name.change.warning.one'])} +

+

+ {intl.formatMessage(messages['account.settings.name.change.warning.two'])} +

+
+ ); + } + + return ( + 0}> + + {intl.formatMessage(messages['account.settings.name.change.id.name.label'])} + + + {renderErrors()} + + ); + } + + function renderContinueButton() { + if (!confirmedWarning) { + return ( + + ); + } + + return ( + + ); + } + + return ( + + +
+ + + {renderTitle()} + + + + + {renderBody()} + + + + + + {intl.formatMessage(messages['account.settings.name.change.cancel'])} + + {renderContinueButton()} + + +
+ +
+ ); +} + +NameChangeModal.propTypes = { + errors: PropTypes.shape({}).isRequired, + formValues: PropTypes.shape({ name: PropTypes.string }).isRequired, + saveState: PropTypes.string, + intl: intlShape.isRequired, +}; + +NameChangeModal.defaultProps = { + saveState: null, +}; + +export default connect(nameChangeSelector)(injectIntl(NameChangeModal)); diff --git a/src/account-settings/name-change/data/actions.js b/src/account-settings/name-change/data/actions.js new file mode 100644 index 0000000..b68fc15 --- /dev/null +++ b/src/account-settings/name-change/data/actions.js @@ -0,0 +1,25 @@ +import { AsyncActionType } from '../../data/utils'; + +export const REQUEST_NAME_CHANGE = new AsyncActionType('ACCOUNT_SETTINGS', 'REQUEST_NAME_CHANGE'); + +export const requestNameChange = (username, newName, verifiedName) => ({ + type: REQUEST_NAME_CHANGE.BASE, + payload: { username, newName, 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, +}); diff --git a/src/account-settings/name-change/data/reducers.js b/src/account-settings/name-change/data/reducers.js new file mode 100644 index 0000000..7bd1e4d --- /dev/null +++ b/src/account-settings/name-change/data/reducers.js @@ -0,0 +1,44 @@ +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; diff --git a/src/account-settings/name-change/data/sagas.js b/src/account-settings/name-change/data/sagas.js new file mode 100644 index 0000000..5a7e622 --- /dev/null +++ b/src/account-settings/name-change/data/sagas.js @@ -0,0 +1,34 @@ +import { put, call, takeEvery } from 'redux-saga/effects'; + +import { postVerifiedName } from '../../data/service'; + +import { + REQUEST_NAME_CHANGE, + requestNameChangeBegin, + requestNameChangeSuccess, + requestNameChangeFailure, +} from './actions'; +import { postNameChange } from './service'; + +export function* handleRequestNameChange(action) { + try { + yield put(requestNameChangeBegin()); + yield call(postNameChange, action.payload.newName); + yield call(postVerifiedName, { + username: action.payload.username, + verified_name: action.payload.verifiedName, + profile_name: action.payload.newName, + }); + 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); +} diff --git a/src/account-settings/name-change/data/service.js b/src/account-settings/name-change/data/service.js new file mode 100644 index 0000000..70a6cfc --- /dev/null +++ b/src/account-settings/name-change/data/service.js @@ -0,0 +1,17 @@ +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; +} diff --git a/src/account-settings/name-change/index.js b/src/account-settings/name-change/index.js new file mode 100644 index 0000000..3e14f58 --- /dev/null +++ b/src/account-settings/name-change/index.js @@ -0,0 +1,4 @@ +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'; diff --git a/src/account-settings/name-change/messages.js b/src/account-settings/name-change/messages.js new file mode 100644 index 0000000..8160aa0 --- /dev/null +++ b/src/account-settings/name-change/messages.js @@ -0,0 +1,56 @@ +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 government-issued ID.', + 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 government 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 doesn’t 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; diff --git a/src/account-settings/name-change/test/NameChange.test.jsx b/src/account-settings/name-change/test/NameChange.test.jsx new file mode 100644 index 0000000..c0f43ae --- /dev/null +++ b/src/account-settings/name-change/test/NameChange.test.jsx @@ -0,0 +1,125 @@ +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 => ( + + + {children} + + + ); + + beforeEach(() => { + store = mockStore(); + props = { + errors: {}, + formValues: { name: 'edx edx' }, + 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 input after clicking continue', async () => { + const getInput = () => screen.queryByPlaceholderText('Enter the name on your government ID'); + + render(reduxWrapper()); + expect(getInput()).toBeNull(); + + const continueButton = screen.getByText('Continue'); + fireEvent.click(continueButton); + + expect(getInput()).toBeTruthy(); + }); + + it('dispatches action on submit', async () => { + const dispatchData = { + payload: { + newName: 'edx edx', + username: 'edx', + verifiedName: 'Verified Name', + }, + type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE', + }; + + render(reduxWrapper()); + + const continueButton = screen.getByText('Continue'); + fireEvent.click(continueButton); + + const input = screen.getByPlaceholderText('Enter the name on your government 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()); + + const continueButton = screen.getByText('Continue'); + fireEvent.click(continueButton); + + const input = screen.getByPlaceholderText('Enter the name on your government 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()); + expect(history.location.pathname).toEqual('/id-verification'); + }); +});