Revert "[MST-535] Prevent IDV name change if name is managed by a third party"

This commit is contained in:
Bianca Severino
2021-03-18 15:51:22 -04:00
committed by GitHub
parent ff2e86fa13
commit ccd2a5c074
9 changed files with 121 additions and 266 deletions

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Next',
description: 'Next button.',
},
'id.verification.support': {
id: 'id.verification.support',
defaultMessage: 'support',
description: 'Website support.',
},
'id.verification.example.card.alt': {
id: 'id.verification.example.card.alt',
defaultMessage: 'Example of a valid identification card with a full name and photo.',

View File

@@ -2,74 +2,24 @@ import React, { useState, useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import { getProfileDataManager } from '../account-settings/data/service';
import PageLoading from '../account-settings/PageLoading';
import { getExistingIdVerification, getEnrollments } from './data/service';
import AccessBlocked from './AccessBlocked';
import { hasGetUserMediaSupport } from './getUserMediaShim';
import { getExistingIdVerification, getEnrollments } from './data/service';
import PageLoading from '../account-settings/PageLoading';
import AccessBlocked from './AccessBlocked';
import IdVerificationContext, { MEDIA_ACCESS, ERROR_REASONS, VERIFIED_MODES } from './IdVerificationContext';
export default function IdVerificationContextProvider({ children }) {
const { authenticatedUser } = useContext(AppContext);
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);
const [idPhotoName, setIdPhotoName] = useState(null);
const [mediaStream, setMediaStream] = useState(null);
const [mediaAccess, setMediaAccess] = useState(
hasGetUserMediaSupport ? MEDIA_ACCESS.PENDING : MEDIA_ACCESS.UNSUPPORTED,
);
const [mediaAccess, setMediaAccess] = useState(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.
// If so, they cannot update their account name manually.
if (authenticatedUser.roles.length > 0) {
setProfileDataManager(
getProfileDataManager(authenticatedUser.username, authenticatedUser.roles),
);
}
}, [authenticatedUser]);
const { authenticatedUser } = useContext(AppContext);
const [optimizelyExperimentName, setOptimizelyExperimentName] = useState('');
const contextValue = {
@@ -81,7 +31,6 @@ export default function IdVerificationContextProvider({ children }) {
mediaAccess,
userId: authenticatedUser.userId,
nameOnAccount: authenticatedUser.name,
profileDataManager,
optimizelyExperimentName,
setExistingIdVerification,
setFacePhotoFile,
@@ -109,6 +58,42 @@ export default function IdVerificationContextProvider({ children }) {
},
};
useEffect(() => {
// Call verification status endpoint to check whether we can verify.
(async () => {
const existingIdV = await getExistingIdVerification();
setExistingIdVerification(existingIdV);
})();
}, []);
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);
}
})();
}, []);
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]);
// If we are waiting for verification status endpoint, show spinner.
if (!existingIdVerification) {
return <PageLoading srMessage="Loading verification status" />;

View File

@@ -1,11 +1,10 @@
import React, {
useContext, useState, useEffect, useRef,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import { Hyperlink, Form } from '@edx/paragon';
import { Form } from '@edx/paragon';
import { Link, useHistory } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
@@ -21,14 +20,14 @@ function GetNameIdPanel(props) {
const nextPanelSlug = useNextPanelSlug(panelSlug);
const {
nameOnAccount, userId, profileDataManager, idPhotoName, setIdPhotoName,
nameOnAccount, userId, idPhotoName, setIdPhotoName,
} = useContext(IdVerificationContext);
const nameOnAccountValue = nameOnAccount || '';
const invalidName = !nameMatches && (!idPhotoName || idPhotoName === nameOnAccount);
const blankName = !nameOnAccount && !idPhotoName;
useEffect(() => {
setIdPhotoName(null);
setIdPhotoName('');
}, []);
useEffect(() => {
@@ -46,39 +45,6 @@ function GetNameIdPanel(props) {
}
}, [nameMatches, blankName]);
const getNameValue = () => {
if (!nameMatches) {
// If we just check that idPhotoName exists, an empty string will cause
// the field to reset to nameOnAccountValue, so check that it is a string here.
if (typeof idPhotoName === 'string') {
return idPhotoName;
}
return nameOnAccountValue;
}
return nameOnAccountValue;
};
const 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 {managerTitle} 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>,
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']);
};
const handleSubmit = (e) => {
e.preventDefault();
// If the input is empty, or if no changes have been made to the
@@ -114,7 +80,7 @@ function GetNameIdPanel(props) {
inline
onChange={() => {
setNameMatches(true);
setIdPhotoName(null);
setIdPhotoName('');
}}
/>
<Form.Check
@@ -139,15 +105,19 @@ function GetNameIdPanel(props) {
size="lg"
type="text"
ref={nameInputRef}
readOnly={nameMatches || profileDataManager}
readOnly={nameMatches}
isInvalid={invalidName || blankName}
aria-describedby="photo-id-name-feedback"
value={getNameValue()}
value={
!nameMatches
? idPhotoName || nameOnAccountValue
: nameOnAccountValue
}
onChange={e => setIdPhotoName(e.target.value)}
data-testid="name-input"
/>
<Form.Control.Feedback id="photo-id-name-feedback" type="invalid">
{getErrorMessage()}
{props.intl.formatMessage(messages['id.verification.account.name.error'])}
</Form.Control.Feedback>
</Form.Group>
</Form>

View File

@@ -1,9 +1,7 @@
import React, { useEffect, useContext } from 'react';
import { Link } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
@@ -13,9 +11,7 @@ import messages from '../IdVerification.messages';
import exampleCard from '../assets/example-card.png';
function ReviewRequirementsPanel(props) {
const {
userId, profileDataManager, setOptimizelyExperimentName,
} = useContext(IdVerificationContext);
const { userId, setOptimizelyExperimentName } = useContext(IdVerificationContext);
const panelSlug = 'review-requirements';
const nextPanelSlug = useNextPanelSlug(panelSlug);
@@ -46,23 +42,6 @@ function ReviewRequirementsPanel(props) {
title={props.intl.formatMessage(messages['id.verification.requirements.title'])}
focusOnMount={false}
>
{profileDataManager && (
<Alert className="alert alert-primary" role="alert">
<FormattedMessage
id="id.verification.requirements.account.managed.alert"
defaultMessage="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 {managerTitle} administrator or {support} for help before completing the Photo Verification process."
description="Alert message informing the user their account data is managed by a third party."
values={{
managerTitle: <strong>{profileDataManager}</strong>,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
{props.intl.formatMessage(messages['id.verification.support'])}
</Hyperlink>
),
}}
/>
</Alert>
)}
<p>
{props.intl.formatMessage(messages['id.verification.requirements.description'])}
</p>

View File

@@ -1,7 +1,7 @@
import React, { useState, useContext } from 'react';
import { getConfig, history } from '@edx/frontend-platform';
import { history } from '@edx/frontend-platform';
import {
Alert, Hyperlink, Input, Button, Spinner,
Input, Button, Spinner, Alert,
} from '@edx/paragon';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
@@ -21,7 +21,6 @@ function SummaryPanel(props) {
const {
facePhotoFile,
idPhotoFile,
profileDataManager,
nameOnAccount,
idPhotoName,
stopUserMedia,
@@ -112,7 +111,7 @@ function SummaryPanel(props) {
</p>
<div className="row mb-4">
<div className="col-6">
<label htmlFor="photo-of-face" className="font-weight-bold">
<label htmlFor="photo-of-face">
{props.intl.formatMessage(messages['id.verification.review.portrait.label'])}
</label>
<ImagePreview
@@ -132,7 +131,7 @@ function SummaryPanel(props) {
</Link>
</div>
<div className="col-6">
<label htmlFor="photo-of-id/edit" className="font-weight-bold">
<label htmlFor="photo-of-id/edit">
{props.intl.formatMessage(messages['id.verification.review.id.label'])}
</label>
<ImagePreview
@@ -154,26 +153,9 @@ function SummaryPanel(props) {
</div>
<CameraHelpWithUpload />
<div className="form-group">
<label htmlFor="name-to-be-used" className="font-weight-bold">
<label htmlFor="name-to-be-used">
{props.intl.formatMessage(messages['id.verification.account.name.label'])}
</label>
{profileDataManager && (
<p id="profile-manager-warning">
<FormattedMessage
id="id.verification.account.name.summary.alert"
defaultMessage="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 {managerTitle} administrator or {support} for help."
description="Alert message informing the user their account data is managed by a third party."
values={{
managerTitle: <strong>{profileDataManager}</strong>,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
{props.intl.formatMessage(messages['id.verification.support'])}
</Hyperlink>
),
}}
/>
</p>
)}
<div className="d-flex">
<Input
id="name-to-be-used"
@@ -181,26 +163,24 @@ function SummaryPanel(props) {
readOnly
value={nameToBeUsed}
onChange={() => {}}
aria-describedby={profileDataManager ? 'profile-manager-warning' : null}
/>
{!profileDataManager && (
<Link
className="btn btn-link ml-3 px-0"
to={{
pathname: 'get-name-id',
state: { fromSummary: true },
<Link
className="btn btn-link ml-3 px-0"
to={{
pathname: 'get-name-id',
state: { fromSummary: true },
}}
>
<FormattedMessage
id="id.verification.account.name.edit"
defaultMessage="Edit{sr}"
description="Button to edit account name, with clarifying information for screen readers."
values={{
sr: <span className="sr-only">Account Name</span>,
}}
>
<FormattedMessage
id="id.verification.account.name.edit"
defaultMessage="Edit {sr}"
description="Button to edit account name, with clarifying information for screen readers."
values={{
sr: <span className="sr-only">Account Name</span>,
}}
/>
</Link>
)}
/>
</Link>
</div>
</div>
<SubmitButton />{' '}

View File

@@ -1,19 +1,11 @@
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';
import { getProfileDataManager } from '../../account-settings/data/service';
import { getExistingIdVerification, getEnrollments } from '../data/service';
import IdVerificationContextProvider from '../IdVerificationContextProvider';
jest.mock('../../account-settings/data/service', () => ({
getProfileDataManager: jest.fn(),
}));
jest.mock('../data/service', () => ({
getExistingIdVerification: jest.fn(),
getEnrollments: jest.fn(() => []),
@@ -30,9 +22,8 @@ describe('IdVerificationContextProvider', () => {
});
it('renders correctly and calls getExistingIdVerification + getEnrollments', async () => {
const context = { authenticatedUser: { userId: 3, roles: [] } };
await act(async () => render((
<AppContext.Provider value={context}>
<AppContext.Provider value={{ authenticatedUser: { userId: 3 } }}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
@@ -41,25 +32,4 @@ describe('IdVerificationContextProvider', () => {
expect(getExistingIdVerification).toHaveBeenCalled();
expect(getEnrollments).toHaveBeenCalled();
});
it('calls getProfileDataManager if the user has any roles', async () => {
const context = {
authenticatedUser: {
userId: 3,
username: 'testname',
roles: ['enterprise_learner'],
},
};
await act(async () => render((
<AppContext.Provider value={context}>
<IntlProvider locale="en">
<IdVerificationContextProvider {...defaultProps} />
</IntlProvider>
</AppContext.Provider>
)));
expect(getProfileDataManager).toHaveBeenCalledWith(
context.authenticatedUser.username,
context.authenticatedUser.roles,
);
});
});

View File

@@ -31,7 +31,11 @@ describe('GetNameIdPanel', () => {
idPhotoFile: 'test.jpg',
};
const getPanel = async () => {
afterEach(() => {
cleanup();
});
it('edits', async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -41,29 +45,16 @@ describe('GetNameIdPanel', () => {
</IntlProvider>
</Router>
)));
};
afterEach(() => {
cleanup();
});
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');
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();
@@ -71,39 +62,36 @@ describe('GetNameIdPanel', () => {
it('disables radio buttons + next button and enables input if account name is blank', async () => {
contextValue.nameOnAccount = '';
await getPanel();
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlGetNameIdPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
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(2);
});
it('routes to SummaryPanel', async () => {
await getPanel();
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlGetNameIdPanel {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
expect(history.location.pathname).toEqual('/summary');
});

View File

@@ -24,7 +24,26 @@ describe('ReviewRequirementsPanel', () => {
const context = { setOptimizelyExperimentName: jest.fn() };
const getPanel = async () => {
afterEach(() => {
cleanup();
});
it('routes to RequestCameraAccessPanel', async () => {
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
<IntlReviewRequirementsPanel {...defaultProps} />
</IntlProvider>
</Router>
)));
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
expect(history.location.pathname).toEqual('/request-camera-access');
});
it('updates optimizely experiment name in context', async () => {
window.experimentVariables = {};
window.experimentVariables.experimentName = 'test-experiment';
await act(async () => render((
<Router history={history}>
<IntlProvider locale="en">
@@ -34,30 +53,6 @@ describe('ReviewRequirementsPanel', () => {
</IntlProvider>
</Router>
)));
};
afterEach(() => {
cleanup();
});
it('routes to RequestCameraAccessPanel', async () => {
await getPanel();
const button = await screen.findByTestId('next-button');
fireEvent.click(button);
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();
const alert = await screen.getAllByText('test-org');
expect(alert.length).toEqual(2);
});
});

View File

@@ -76,13 +76,6 @@ describe('SummaryPanel', () => {
expect(uploadButton).toBeVisible();
});
it('displays warning if account is managed by a third party', async () => {
contextValue.profileDataManager = 'test-org';
await getPanel();
const warning = await screen.getAllByText('test-org');
expect(warning.length).toEqual(2);
});
it('submits', async () => {
const verificationData = {
facePhotoFile: contextValue.facePhotoFile,