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:
committed by
Bianca Severino
parent
93ef8d2b04
commit
1a76468587
18
package-lock.json
generated
18
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
101
src/id-verification/Camera.jsx
Normal file
101
src/id-verification/Camera.jsx
Normal 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;
|
||||
19
src/id-verification/CameraHelp.jsx
Normal file
19
src/id-verification/CameraHelp.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/id-verification/IdVerificationContext.jsx
Normal file
106
src/id-verification/IdVerificationContext.jsx
Normal 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,
|
||||
};
|
||||
73
src/id-verification/IdVerificationPage.jsx
Normal file
73
src/id-verification/IdVerificationPage.jsx
Normal 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));
|
||||
|
||||
28
src/id-verification/ImageFileUpload.jsx
Normal file
28
src/id-verification/ImageFileUpload.jsx
Normal 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,
|
||||
};
|
||||
18
src/id-verification/ImagePreview.jsx
Normal file
18
src/id-verification/ImagePreview.jsx
Normal 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,
|
||||
};
|
||||
62
src/id-verification/_id-verification.scss
Normal file
62
src/id-verification/_id-verification.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
3
src/id-verification/data/camera-shutter.base64.json
Normal file
3
src/id-verification/data/camera-shutter.base64.json
Normal file
File diff suppressed because one or more lines are too long
3
src/id-verification/data/selectors.js
Normal file
3
src/id-verification/data/selectors.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export const storeName = 'idVerifiction';
|
||||
|
||||
export const idVerificationSelector = state => ({ ...state[storeName] });
|
||||
71
src/id-verification/data/service.js
Normal file
71
src/id-verification/data/service.js
Normal 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?
|
||||
}
|
||||
}
|
||||
59
src/id-verification/getUserMediaShim.js
Normal file
59
src/id-verification/getUserMediaShim.js
Normal 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 };
|
||||
4
src/id-verification/index.js
Normal file
4
src/id-verification/index.js
Normal 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';
|
||||
43
src/id-verification/panels/BasePanel.jsx
Normal file
43
src/id-verification/panels/BasePanel.jsx
Normal 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,
|
||||
};
|
||||
70
src/id-verification/panels/GetNameIdPanel.jsx
Normal file
70
src/id-verification/panels/GetNameIdPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/id-verification/panels/IdContextPanel.jsx
Normal file
33
src/id-verification/panels/IdContextPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/id-verification/panels/PortraitPhotoContextPanel.jsx
Normal file
33
src/id-verification/panels/PortraitPhotoContextPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/id-verification/panels/RequestCameraAccessPanel.jsx
Normal file
72
src/id-verification/panels/RequestCameraAccessPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/id-verification/panels/ReviewRequirementsPanel.jsx
Normal file
44
src/id-verification/panels/ReviewRequirementsPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/id-verification/panels/SubmittedPanel.jsx
Normal file
18
src/id-verification/panels/SubmittedPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/id-verification/panels/SummaryPanel.jsx
Normal file
109
src/id-verification/panels/SummaryPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/id-verification/panels/TakeIdPhotoPanel.jsx
Normal file
45
src/id-verification/panels/TakeIdPhotoPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/id-verification/panels/TakePortraitPhotoPanel.jsx
Normal file
46
src/id-verification/panels/TakePortraitPhotoPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/id-verification/routing-utilities.js
Normal file
52
src/id-verification/routing-utilities.js
Normal 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;
|
||||
};
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user