Add ID verification page

Fix 404 page from displaying when it shouldn't

Was displaying below the settings page itself

Add panels for each step of the verification flow

Add IdVerificationContext

Add BasePanel

Revert "Add BasePanel"

This reverts commit 2ddca14b4b8b35cd23f0525f49070753c09d361d.

Add BasePanel

Focus heading elements on mount

Add current account name to GetNameIdPanel

Fix nested button focus problem

Add a friendly camera permission request

Remove double ctas

lint

Add initial service.js file; not tested

Add image file uploads

fix temp heading of photo context panel

Add photo id name input

Add redirect logic for users who find themselves too far in the flow

add wip SubmittedPanel

Wire up Submit button

Update routing-utilities.js

Preview image on summarypenl; still buggy

Add some content and styles

update first panel content

Update headings

Use ImagePreview component on SummaryPanel

Fix service.js; it works now

More content and fix for name input

ImagePreview: Change 'name' to 'alt'

Add content to camera request

Fix ImagePreview alt text

Iterating on SummaryPage

Add privacy info modal

Update name entry

Make the footer sticky to the bottom

Update upload photo screens

Add photo to name panel

camera component

overlay camera button

implement camera component

remove camera tracking

Add retake photo buttons

Add service for verification status endpoint

delete old css

lint fix

Better name edit state

portrait photo page

Add edit name from summary

Add content to submitted screen

lint

Rename status service function

Add clarity to summary view text

First pass at gating based on existing idv

Clean up IDv-status-gating screens

finish up webcam/photo pages

remove unused tracking library
This commit is contained in:
Kyle McCormick
2020-04-16 10:57:11 -04:00
committed by Bianca Severino
parent 93ef8d2b04
commit 1a76468587
27 changed files with 1140 additions and 11 deletions

18
package-lock.json generated
View File

@@ -11287,6 +11287,11 @@
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
"dev": true
},
"jslib-html5-camera-photo": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/jslib-html5-camera-photo/-/jslib-html5-camera-photo-3.1.3.tgz",
"integrity": "sha512-8vJwX0a6o82+AJur7KntGvGLYhfxN5h7HRNd77zuoLOi3/Rd+5L47gL9MwTHdtWu3J3VC3a3MuC4KiW8l8HXzA=="
},
"json-buffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
@@ -14728,10 +14733,9 @@
"dev": true
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
"dev": true
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz",
"integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw=="
},
"query-string": {
"version": "5.1.1",
@@ -15582,6 +15586,12 @@
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
"dev": true
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
"dev": true
},
"tough-cookie": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",

View File

@@ -44,6 +44,7 @@
"form-urlencoded": "4.0.1",
"formdata-polyfill": "3.0.19",
"history": "4.10.1",
"jslib-html5-camera-photo": "^3.1.3",
"lodash.camelcase": "4.3.0",
"lodash.debounce": "4.0.8",
"lodash.findindex": "4.6.0",
@@ -56,6 +57,7 @@
"memoize-one": "5.1.1",
"newrelic": "5.13.1",
"prop-types": "15.7.2",
"qs": "6.9.3",
"react": "16.10.2",
"react-dom": "16.10.2",
"react-redux": "7.1.3",

View File

@@ -0,0 +1,101 @@
import React from 'react';
import PropTypes from 'prop-types';
import CameraPhoto, { FACING_MODES } from 'jslib-html5-camera-photo';
import shutter from './data/camera-shutter.base64.json'
const PHOTO_PROMTS = {
TAKE_PHOTO: 'Take Photo',
RETAKE_PHOTO: 'Retake Photo'
}
class Camera extends React.Component {
constructor (props, context) {
super(props, context);
this.cameraPhoto = null;
this.videoRef = React.createRef();
this.state = {
trackedObject: null,
dataUri: ''
}
}
componentDidMount () {
this.cameraPhoto = new CameraPhoto(this.videoRef.current);
this.cameraPhoto.startCameraMaxResolution(FACING_MODES.USER)
}
takePhoto () {
if (this.state.dataUri) {
return this.reset()
}
const config = {
sizeFactor: 1
};
this.playShutterClick()
let dataUri = this.cameraPhoto.getDataUri(config);
this.setState({ dataUri });
this.props.onImageCapture(dataUri)
}
playShutterClick () {
let audio = new Audio('data:audio/mp3;base64,' + shutter.base64);
audio.play();
}
reset () {
this.setState({dataUri: ''})
}
printTrackingInfo () {
let trackedObject = this.state.trackedObject;
if (!trackedObject) {
trackedObject = {
x: 'N/A',
y: 'N/A',
width: 'N/A',
height: 'N/A',
}
}
var res = `
x: ${trackedObject.x}
y: ${trackedObject.y}
width: ${trackedObject.width}
height: ${trackedObject.height}
`
return res
}
render () {
let cameraFlashClass = this.state.dataUri ? 'do-transition camera-flash' : 'camera-flash';
return (
<div className="camera-outer-wrapper shadow">
<div className="camera-wrapper">
<div className={ cameraFlashClass }></div>
<video
ref={this.videoRef}
autoPlay={true}
className="camera-video"
style={{ display: this.state.dataUri ? 'none' : 'block' }}
/>
<img
alt="imgCamera"
src={this.state.dataUri}
className="camera-video"
style={{ display: this.state.dataUri ? 'block' : 'none' }}
/>
</div>
<button className="btn btn-primary camera-btn" accessKey="c" onClick={ () => {
this.takePhoto();
}}> {this.state.dataUri ? PHOTO_PROMTS.RETAKE_PHOTO : PHOTO_PROMTS.TAKE_PHOTO} </button>
</div>
);
}
}
Camera.propTypes = {
onImageCapture: PropTypes.func.isRequired,
}
export default Camera;

View File

@@ -0,0 +1,19 @@
import React from 'react';
export default function CameraHelp() {
return (
<div>
<h6>What if I can't see the camera image or if I can't see my photo to determine which side is visible</h6>
<p>
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.
The most common reason for rejection is in ability to read the text on the ID card.
</p>
<h6>What if I have difficulty holding my head in position relative to the camera?</h6>
<p>If you require assistance with taking a photo for submission, contact edX support for additional suggestions.</p>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import React, { useState, useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig } from '@edx/frontend-platform';
import { hasGetUserMediaSupport } from './getUserMediaShim';
import { getExistingIdVerification } from './data/service';
import PageLoading from '../account-settings/PageLoading'
const IdVerificationContext = React.createContext({});
const MEDIA_ACCESS = {
PENDING: 'pending',
UNSUPPORTED: 'unsupported',
DENIED: 'denied',
GRANTED: 'granted',
};
function IdVerificationContextProvider({ children }) {
const [existingIdVerification, setExistingIdVerification] = useState(null);
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 { authenticatedUser } = useContext(AppContext);
const contextValue = {
existingIdVerification,
facePhotoFile,
idPhotoFile,
idPhotoName,
mediaStream,
mediaAccess,
nameOnAccount: authenticatedUser.name,
setExistingIdVerification,
setFacePhotoFile,
setIdPhotoFile,
setIdPhotoName,
tryGetUserMedia: async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
setMediaAccess(MEDIA_ACCESS.GRANTED);
setMediaStream(stream);
// If we would like to stop the stream immediately. I guess we can leave it open
// const tracks = stream.getTracks();
// tracks.forEach(track => track.stop());
} catch (err) {
setMediaAccess(MEDIA_ACCESS.DENIED);
}
},
};
// Call verification status endpoint to check whether we can verify.
useEffect(() => {(async () => {
const existingIdV = await getExistingIdVerification();
setExistingIdVerification(existingIdV);
})()}, []);
// If we are waiting for verification status endpoint, show spinner.
if (!existingIdVerification) {
return <PageLoading srMessage='Loading verification status' />;
}
if (!existingIdVerification.canVerify) {
const status = existingIdVerification.status;
return (
<div>
<h3 aria-level="1" tabIndex="-1">Identity Verification</h3>
{status === 'pending' || status == 'approved'
? <p>
You have already submitted your verification information.
You will see a message on your dashboard when the verification process
is complete (usually within 1-2 days).
</p>
: <p>
You cannot verify your identity at this time.
</p>
}
<a className="btn btn-primary" href={`${getConfig().LMS_BASE_URL}/dashboard`}>
Return to Your Dashboard
</a>
</div>
);
}
return (
<IdVerificationContext.Provider value={contextValue}>
{children}
</IdVerificationContext.Provider>
);
}
IdVerificationContextProvider.propTypes = {
children: PropTypes.node,
};
IdVerificationContextProvider.defaultProps = {
children: undefined,
};
export {
IdVerificationContext,
IdVerificationContextProvider,
MEDIA_ACCESS,
};

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { connect } from 'react-redux';
import { Route, Switch, Redirect, useRouteMatch } from 'react-router-dom';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Modal, Button } from '@edx/paragon';
import { idVerificationSelector } from './data/selectors';
import './getUserMediaShim';
import { IdVerificationContextProvider } from './IdVerificationContext';
import ReviewRequirementsPanel from './panels/ReviewRequirementsPanel';
import RequestCameraAccessPanel from './panels/RequestCameraAccessPanel';
import PortraitPhotoContextPanel from './panels/PortraitPhotoContextPanel';
import TakePortraitPhotoPanel from './panels/TakePortraitPhotoPanel';
import IdContextPanel from './panels/IdContextPanel';
import GetNameIdPanel from './panels/GetNameIdPanel';
import TakeIdPhotoPanel from './panels/TakeIdPhotoPanel';
import SummaryPanel from './panels/SummaryPanel';
import SubmittedPanel from './panels/SubmittedPanel';
// eslint-disable-next-line react/prefer-stateless-function
function IdVerificationPage() {
const { path } = useRouteMatch();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div className="page__id-verification container-fluid py-5">
<div className="row">
<div className="col-lg-6 col-md-8">
<IdVerificationContextProvider>
<Switch>
<Route exact path={path}>
<Redirect to={`${path}/review-requirements`} />
</Route>
<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>
</div>
<div className="col-lg-6 col-md-4 pt-md-0 pt-4 text-right">
<Button className="btn-link px-0" onClick={() => setIsModalOpen(true)}>
Privacy Information
</Button>
</div>
</div>
<Modal
open={isModalOpen}
title="Privacy Information"
body={(
<div>
<h6>Why does edX need my photo?</h6>
<p>We use your verification photos to confirm your identity and ensure the validity of your certificate.</p>
<h6>What does edX do with this photo?</h6>
<p>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.</p>
</div>
)}
onClose={() => setIsModalOpen(false)}
/>
</div>
);
}
export default connect(idVerificationSelector, {
})(injectIntl(IdVerificationPage));

View File

@@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
export default function ImageFileUpload({ onFileChange }) {
const handleChange = useCallback((e) => {
if (e.target.files.length === 0) {
// do something else
return;
}
const fileObject = e.target.files[0];
const fileReader = new FileReader();
fileReader.addEventListener('load', () => onFileChange(fileReader.result));
fileReader.readAsDataURL(fileObject);
}, []);
return (
<input
type="file"
accept="image/*"
onChange={handleChange}
/>
);
}
ImageFileUpload.propTypes = {
onFileChange: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function ImagePreview({ src, alt, id }) {
return (
<div id={id} className="image-preview">
<img style={{ objectFit: 'contain' }} src={src} alt={alt} />
</div>
);
}
ImagePreview.propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
id: PropTypes.string,
};

View File

@@ -0,0 +1,62 @@
.page__id-verification {
.verification-panel {
.card {
border-top: solid 4px theme-color('warning');
}
.image-preview {
margin-bottom: 1rem;;
max-width: 20rem;
img {
display: block;
max-width: 100%;
max-height: 10rem;
}
}
}
.action-row {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
.btn-primary {
min-width: 8rem;
margin-left: 1rem;
}
.btn-link {
padding-left: 0;
padding-right: 0;
text-decoration: underline;
align-self: center;
}
}
.camera-outer-wrapper {
margin-bottom: 15px;
text-align: center;
border-radius: 0.3rem;
}
.camera-wrapper {
position: relative;
width: 100%;
height: 100%
}
.camera-video {
width: 100%;
}
.camera-btn {
margin: 10px;
}
.camera-flash {
position: absolute;
height: 100%;
width: 100%;
opacity :1;
transition:opacity .9s ease-out;
}
.camera-flash.do-transition {
opacity: 0;
background: white;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
export const storeName = 'idVerifiction';
export const idVerificationSelector = state => ({ ...state[storeName] });

View File

@@ -0,0 +1,71 @@
import qs from 'qs';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
/**
* Get ID verification status from LMS.
*
* Returns {
* status: String,
* expires: String|null,
* canVerify: Boolean,
* }
*/
export async function getExistingIdVerification() {
const url = `${getConfig().LMS_BASE_URL}/verify_student/status/`;
const requestConfig = {
headers: { Accept: 'application/json' },
};
try {
const response = await getAuthenticatedHttpClient().get(url, requestConfig);
return {
status: response.data.status || null,
expires: response.data.expires || null,
canVerify: response.data.can_verify || false,
};
} catch (e) {
return { status: null, expires: null, canVerify: false };
}
}
/**
* Submit ID verifiction to LMS.
*
* verificationData should take the shape of:
* - facePhotoFile (String): Base64-encoded image.
* - idPhotoFile (String|null): Optional Base64-encoded image
* - idPhotoName (String|null): Optional string to change the user's name to.
* - courseRunKey (String|null): Optional course run to redirect to.
*
* Returns { success: Boolean, message: String|null }
*/
export async function submitIdVerfication(verificationData) {
const keyMap = {
facePhotoFile: 'face_image',
idPhotoFile: 'photo_id_image',
idPhotoName: 'full_name',
courseRunKey: 'course_id',
};
const postData = {};
// Don't include blank/null/undefined values.
// Note that this will also drop the value `false`.
Object.keys(keyMap).forEach((jsKey) => {
const apiKey = keyMap[jsKey];
if (verificationData[jsKey]) {
postData[apiKey] = verificationData[jsKey];
}
});
const url = `${getConfig().LMS_BASE_URL}/verify_student/submit-photos/`;
const urlEncodedPostData = qs.stringify(postData);
const requestConfig = {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
};
try {
await getAuthenticatedHttpClient().post(url, urlEncodedPostData, requestConfig);
return { success: true, message: null };
} catch (e) {
return { success: false, message: String(e) }; // TODO: is String(e) right?
}
}

View File

@@ -0,0 +1,59 @@
/**
* This polyfill is from MDN:
* https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
*
* Their description:
* "Here's an example of using navigator.mediaDevices.getUserMedia(), with a
* polyfill to cope with older browsers. Note that this polyfill does not
* correct for legacy differences in constraints syntax, which means constraints
* won't work well across browsers. It is recommended to use the adapter.js
* polyfill instead, which does handle constraints."
*
* Despite the lack of support for differences in constraints we'll use this
* since it's small and simple and we don't have a need for constraints at the
* moment. I've added an export hasGetUserMediaSupport before the polyfill to
* help us understand support before making calls to getUserMedia.
*/
// IIFE to check getUserMedia support. Must be run before the polyfill.
const hasGetUserMediaSupport = (() => {
// Modern API
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
return true;
}
// Deprecated APIs
if (navigator.webkitGetUserMedia || navigator.mozGetUserMedia) {
return true;
}
return false;
})();
// Older browsers might not implement mediaDevices at all, so we set an empty object first
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
// Some browsers partially implement mediaDevices. We can't just assign an object
// with getUserMedia as it would overwrite existing properties.
// Here, we will just add the getUserMedia property if it's missing.
if (navigator.mediaDevices.getUserMedia === undefined) {
// eslint-disable-next-line func-names
navigator.mediaDevices.getUserMedia = function (constraints) {
// First get ahold of the legacy getUserMedia, if present
const getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
// Some browsers just don't implement it - return a rejected promise with an error
// to keep a consistent interface
if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia is not implemented in this browser'));
}
// Otherwise, wrap the call to the old navigator.getUserMedia with a Promise
return new Promise(((resolve, reject) => {
getUserMedia.call(navigator, constraints, resolve, reject);
}));
};
}
// eslint-disable-next-line import/prefer-default-export
export { hasGetUserMediaSupport };

View File

@@ -0,0 +1,4 @@
export { default } from './IdVerificationPage';
// export { default as reducer } from './data/reducers';
// export { default as saga } from './data/sagas';
// export { storeName } from './data/selectors';

View File

@@ -0,0 +1,43 @@
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router';
import { useVerificationRedirectSlug } from '../routing-utilities';
export default function BasePanel({
children,
focusOnMount,
name,
title,
}) {
const headingRef = useRef();
// focus heading element on mount
useEffect(() => {
if (focusOnMount && headingRef.current) {
headingRef.current.focus();
}
}, []);
const redirectSlug = useVerificationRedirectSlug(name);
if (redirectSlug) {
return <Redirect to={redirectSlug} />;
}
return (
<div className={`verification-panel ${name}-panel`}>
<h3 aria-level="1" ref={headingRef} tabIndex="-1">{title}</h3>
{children}
</div>
);
}
BasePanel.propTypes = {
children: PropTypes.node.isRequired,
focusOnMount: PropTypes.bool,
name: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
};
BasePanel.defaultProps = {
focusOnMount: true,
};

View File

@@ -0,0 +1,70 @@
import React, { useContext, useState, useEffect, useRef } from 'react';
import { Input, Button } from '@edx/paragon';
import { Link } from 'react-router-dom';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
import { IdVerificationContext } from '../IdVerificationContext';
import ImagePreview from '../ImagePreview';
export default function GetNameIdPanel() {
const panelSlug = 'get-name-id';
const [isEditing, setIsEditing] = useState(false);
const nameInputRef = useRef();
const nextPanelSlug = useNextPanelSlug(panelSlug);
useEffect(() => {
if (isEditing && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [isEditing]);
const {
nameOnAccount, idPhotoName, setIdPhotoName, idPhotoFile,
} = useContext(IdVerificationContext);
const nameOnAccountValue = nameOnAccount || '';
return (
<BasePanel
name={panelSlug}
title="Account Name Check"
>
<p>Please check the Account Name below to ensure it matches the name on your ID. If not, click "Edit".</p>
<div className="alert alert-warning">
<strong>Please Note:</strong> any edit to your name will be saved to your account and can be reviewed on <Link to="/">Account Settings</Link>.
</div>
<div className="form-group">
<label htmlFor="photo-id-name">Name</label>
<div className="d-flex">
<Input
id="photo-id-name"
type="text"
ref={nameInputRef}
disabled={!isEditing}
readOnly={!isEditing}
value={idPhotoName || nameOnAccountValue}
onChange={e => setIdPhotoName(e.target.value)}
/>
{!isEditing && (
<Button
className="btn-link px-0 ml-3"
onClick={() => setIsEditing(true)}
>
Edit
</Button>
)}
</div>
</div>
<ImagePreview
id="photo-of-id"
src={idPhotoFile}
alt="Photo of your ID to be submitted."
/>
<div className="action-row">
<Link to={nextPanelSlug} className="btn btn-primary">
{isEditing ? 'Save' : 'Next'}
</Link>
</div>
</BasePanel>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
export default function IdContextPanel() {
const panelSlug = 'id-context';
const nextPanelSlug = useNextPanelSlug(panelSlug);
return (
<BasePanel
name={panelSlug}
title="Helpful ID Tips"
>
<p>Next you'll need an eligible photo, make sure that:</p>
<div className="card mb-4 shadow">
<div className="card-body">
<h6>Photo Tips</h6>
<p>To take a successful photo, make sure that:</p>
<ul className="mb-0">
<li>Your ID is well-lit.</li>
<li>Ensure that you can see your photo and clearly read your name.</li>
</ul>
</div>
</div>
<div className="action-row">
<Link to={nextPanelSlug} className="btn btn-primary">
Next
</Link>
</div>
</BasePanel>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
export default function PortraitPhotoContextPanel() {
const panelSlug = 'portrait-photo-context';
const nextPanelSlug = useNextPanelSlug(panelSlug);
return (
<BasePanel
name={panelSlug}
title="Helpful Photo Tips"
>
<p>Next, we'll need you to take a photo of your face. Please review the helpful tips below.</p>
<div className="card mb-4 shadow">
<div className="card-body">
<h6>Photo Tips</h6>
<p>To take a successful photo, make sure that:</p>
<ul className="mb-0">
<li>Your face is well-lit.</li>
<li>Your entire face fits inside the frame.</li>
</ul>
</div>
</div>
<div className="action-row">
<Link to={nextPanelSlug} className="btn btn-primary">
Next
</Link>
</div>
</BasePanel>
);
}

View File

@@ -0,0 +1,72 @@
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { Collapsible } from '@edx/paragon';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
import { IdVerificationContext, MEDIA_ACCESS } from '../IdVerificationContext';
export default function RequestCameraAccessPanel() {
const panelSlug = 'request-camera-access';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { tryGetUserMedia, mediaAccess } = useContext(IdVerificationContext);
return (
<BasePanel
name={panelSlug}
title="Camera Permissions"
>
{mediaAccess === MEDIA_ACCESS.PENDING && (
<div>
<p>In order to take a photo using your webcam, you may receive a browser prompt for access to your camera. <strong>Please make sure to click "Allow"</strong></p>
<div className="action-row">
<button className="btn btn-primary" onClick={tryGetUserMedia}>
Enable Camera
</button>
<Collapsible.Advanced className="mr-auto">
<Collapsible.Visible whenClosed>
<Collapsible.Trigger tag="button" className="btn btn-link px-0">
Having problems?
</Collapsible.Trigger>
</Collapsible.Visible>
<Collapsible.Body>
<Link to={nextPanelSlug} className="btn btn-link">
Skip and upload image files instead
</Link>
</Collapsible.Body>
</Collapsible.Advanced>
</div>
</div>
)}
{mediaAccess === MEDIA_ACCESS.GRANTED && (
<div>
<p>
Looks like your camera is working and ready.
</p>
<div className="action-row">
<Link to={nextPanelSlug} className="btn btn-primary">
Next
</Link>
</div>
</div>
)}
{[MEDIA_ACCESS.UNSUPPORTED, MEDIA_ACCESS.DENIED].includes(mediaAccess) && (
<div>
<p>
It looks like we're unable to access your camera. You will need to upload
image files of you and your photo id.
</p>
<div className="action-row">
<Link to={nextPanelSlug} className="btn btn-primary">
Next
</Link>
</div>
</div>
)}
</BasePanel>
);
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
export default function ReviewRequirementsPanel() {
const panelSlug = 'review-requirements';
const nextPanelSlug = useNextPanelSlug(panelSlug);
return (
<BasePanel
name={panelSlug}
title="Photo Verification Requirements"
focusOnMount={false}
>
<p>
In order to complete Photo Verification online, you will need the following
</p>
<div className="card mb-4 shadow">
<div className="card-body">
<h6>Device with Camera</h6>
<p className="mb-0">You need a device that has a camera. If you receive a browser prompt for access to your camera, please make sure to click <strong>Allow</strong>.</p>
</div>
</div>
<div className="card mb-4 shadow">
<div className="card-body">
<h6>Photo Identification</h6>
<p className="mb-0">You need a valid ID that contains your full name and photo.</p>
</div>
</div>
<h4 className="mb-3">Privacy Information</h4>
<h6>Why does edX need my photo?</h6>
<p>We use your verification photos to confirm your identity and ensure the validity of your certificate.</p>
<h6>What does edX do with this photo?</h6>
<p>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.</p>
<div className="action-row">
<Link to={nextPanelSlug} className="btn btn-primary">
Next
</Link>
</div>
</BasePanel>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useNextPanelSlug } from '../routing-utilities';
import BasePanel from './BasePanel';
export default function SubmittedPanel() {
const panelSlug = 'submitted';
return (
<BasePanel
name={panelSlug}
title="Identity Verification in Progress"
>
<p>We have received you information and are verifiying your identity. You will see a message on your dashboard when the verification process is complete (usually within 1-2 days). In the meantime, you can still access all available course content.</p>
<a className="btn btn-primary" href={`${getConfig().LMS_BASE_URL}/dashboard`}>Return to Your Dashboard</a>
</BasePanel>
);
}

View File

@@ -0,0 +1,109 @@
import React, { useContext } from 'react';
import { history } from '@edx/frontend-platform';
import { Input, Button } from '@edx/paragon';
import { Link } from 'react-router-dom';
import { useNextPanelSlug } from '../routing-utilities';
import { submitIdVerfication } from '../data/service';
import BasePanel from './BasePanel';
import { IdVerificationContext } from '../IdVerificationContext';
import ImagePreview from '../ImagePreview';
export default function SummaryPanel() {
const panelSlug = 'summary';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const {
facePhotoFile,
idPhotoFile,
nameOnAccount,
idPhotoName,
} = useContext(IdVerificationContext);
const nameToBeUsed = idPhotoName || nameOnAccount || '';
const courseRunKey = null; // TODO: Implement course run key
function SubmitButton() {
function handleClick(e) {
const verificationData = {
facePhotoFile,
idPhotoFile,
idPhotoName,
courseRunKey,
};
const { success, message } = submitIdVerfication(verificationData);
history.push(nextPanelSlug);
}
return (
<Button className="btn btn-primary" onClick={handleClick}>
Confirm
</Button>
);
}
return (
<BasePanel
name={panelSlug}
title="Review Your Photos"
>
<p>Make sure we can verify your identity with the photos and information you have provided.</p>
<div className="row mb-4">
<div className="col-6">
<label htmlFor="photo-of-face">Your Portrait</label>
<ImagePreview
id="photo-of-face"
src={facePhotoFile}
alt="Photo of your face to be submitted."
/>
<Link
className="btn btn-inverse-primary shadow"
to={{
pathname: 'take-portrait-photo',
state: { fromSummary: true },
}}
>
Retake Portrait Photo
</Link>
</div>
<div className="col-6">
<label htmlFor="photo-of-id/edit">Your Photo ID</label>
<ImagePreview
id="photo-of-id"
src={idPhotoFile}
alt="Photo of your ID to be submitted."
/>
<Link
className="btn btn-inverse-primary shadow"
to={{
pathname: 'take-id-photo',
state: { fromSummary: true },
}}
>
Retake ID Photo
</Link>
</div>
</div>
<div className="form-group">
<label htmlFor="name-to-be-used">Name on ID</label>
<div className="d-flex">
<Input
id="name-to-be-used"
type="text"
readOnly
value={nameToBeUsed}
onChange={() => {}}
/>
<Link
className="btn btn-link ml-3 px-0"
to={{
pathname: 'get-name-id',
state: { fromSummary: true },
}}
>
Edit
</Link>
</div>
</div>
<SubmitButton />
</BasePanel>
);
}

View File

@@ -0,0 +1,45 @@
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
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, MEDIA_ACCESS } from '../IdVerificationContext';
export default function TakeIdPhotoPanel() {
const panelSlug = 'take-id-photo';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { setIdPhotoFile, idPhotoFile, mediaAccess } = useContext(IdVerificationContext);
const shouldUseCamera = mediaAccess === MEDIA_ACCESS.GRANTED;
return (
<BasePanel
name={panelSlug}
title={shouldUseCamera ? 'Take ID Photo' : 'Upload Your ID Photo'}
>
<div>
{idPhotoFile && !shouldUseCamera && <ImagePreview src={idPhotoFile} alt="Preview of photo of ID." />}
{shouldUseCamera ? (
<div>
<p>When your ID is in position, use the Take Photo button below to take your photo.</p>
<Camera onImageCapture={setIdPhotoFile} />
</div>
) : (
<div>
<p>Please upload a ID photo. Ensure the entire ID fits inside the frame and is well-lit. (Supported formats: .jpg, .jpeg, .png)</p>
<ImageFileUpload onFileChange={setIdPhotoFile} />
</div>
)}
</div>
<div className="action-row" style={{ visibility: idPhotoFile ? 'unset' : 'hidden' }}>
<Link to={nextPanelSlug} className="btn btn-primary">
Next
</Link>
</div>
{shouldUseCamera && <CameraHelp/>}
</BasePanel>
);
}

View File

@@ -0,0 +1,46 @@
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
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, MEDIA_ACCESS } from '../IdVerificationContext';
export default function TakePortraitPhotoPanel() {
const panelSlug = 'take-portrait-photo';
const nextPanelSlug = useNextPanelSlug(panelSlug);
const { setFacePhotoFile, facePhotoFile, mediaAccess } = useContext(IdVerificationContext);
const shouldUseCamera = mediaAccess === MEDIA_ACCESS.GRANTED;
return (
<BasePanel
name={panelSlug}
title={shouldUseCamera ? 'Take Your Photo' : 'Upload Your Portrait Photo'}
>
<div>
{facePhotoFile && !shouldUseCamera && <ImagePreview src={facePhotoFile} alt="Preview of photo of user's face." />}
{shouldUseCamera ? (
<div>
<p>When your face is in position, use the Take Photo button below to take your photo.</p>
<Camera onImageCapture={setFacePhotoFile} />
</div>
) : (
<div>
<p>Please upload a portrait photo. Ensure your entire face fits inside the frame and is well-lit. (Supported formats: .jpg, .jpeg, .png)</p>
<ImageFileUpload onFileChange={setFacePhotoFile} />
</div>
)}
</div>
<div className="action-row" style={{ visibility: facePhotoFile ? 'unset' : 'hidden' }}>
<Link to={nextPanelSlug} className="btn btn-primary">
Next
</Link>
</div>
{shouldUseCamera && <CameraHelp/>}
</BasePanel>
);
}

View File

@@ -0,0 +1,52 @@
import { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { IdVerificationContext } from './IdVerificationContext';
const panelSteps = [
'review-requirements',
'request-camera-access',
'portrait-photo-context',
'take-portrait-photo',
'id-context',
'take-id-photo',
'get-name-id',
'summary',
'submitted',
];
// 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 location = useLocation();
const isFromSummary = location.state && location.state.fromSummary;
if (isFromSummary) {
return 'summary';
}
const nextIndex = panelSteps.indexOf(originSlug) + 1;
return nextIndex < panelSteps.length ? panelSteps[nextIndex] : null;
};
// 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) => {
// TODO: remove this short-circuit after development is done
return null;
// eslint-disable-next-line no-unreachable
const { facePhotoFile, idPhotoFile } = useContext(IdVerificationContext);
const indexOfCurrentPanel = panelSteps.indexOf(slug);
if (!facePhotoFile) {
if (indexOfCurrentPanel > panelSteps.indexOf('take-portrait-photo')) {
return 'portrait-photo-context';
}
} else if (!idPhotoFile) {
if (indexOfCurrentPanel > panelSteps.indexOf('take-id-photo')) {
return 'id-context';
}
}
// The user has satisfied requirements to view the panel they're on.
return null;
};

View File

@@ -11,6 +11,7 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot
import configureStore from './data/configureStore';
import AccountSettingsPage, { NotFoundPage } from './account-settings';
import IdVerificationPage from './id-verification';
import CoachingConsent from './account-settings/coaching/CoachingConsent';
import appMessages from './i18n';
@@ -18,11 +19,11 @@ import './index.scss';
import './assets/favicon.ico';
const HeaderFooterLayout = ({ children }) => (
<div>
<div className="d-flex flex-column" style={{ minHeight: '100vh' }}>
<Header />
<main>
{children}
</main>
<main className="flex-grow-1">
{children}
</main>
<Footer />
</div>
);
@@ -33,9 +34,12 @@ subscribe(APP_READY, () => {
<Switch>
<Route path="/coaching_consent" component={CoachingConsent} />
<HeaderFooterLayout>
<Route exact path="/" component={AccountSettingsPage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="*" component={NotFoundPage} />
<Switch>
<Route path="/id-verification" component={IdVerificationPage} />
<Route exact path="/" component={AccountSettingsPage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="*" component={NotFoundPage} />
</Switch>
</HeaderFooterLayout>
</Switch>
</AppProvider>,

View File

@@ -8,6 +8,7 @@ $fa-font-path: "~font-awesome/fonts";
@import "~@edx/frontend-component-footer/dist/footer";
@import "./account-settings/style";
@import "./id-verification/id-verification";
.word-break-all {
word-break: break-all !important;