diff --git a/package-lock.json b/package-lock.json index fe0ed04..a883d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4235419..3229f80 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/id-verification/Camera.jsx b/src/id-verification/Camera.jsx new file mode 100644 index 0000000..360148a --- /dev/null +++ b/src/id-verification/Camera.jsx @@ -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 ( +
+
+
+
+ +
+ ); + } +} + +Camera.propTypes = { + onImageCapture: PropTypes.func.isRequired, +} + +export default Camera; \ No newline at end of file diff --git a/src/id-verification/CameraHelp.jsx b/src/id-verification/CameraHelp.jsx new file mode 100644 index 0000000..d02f409 --- /dev/null +++ b/src/id-verification/CameraHelp.jsx @@ -0,0 +1,19 @@ + +import React from 'react'; + +export default function CameraHelp() { + return ( +
+
What if I can't see the camera image or if I can't see my photo to determine which side is visible
+

+ 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. +

+
What if I have difficulty holding my head in position relative to the camera?
+

If you require assistance with taking a photo for submission, contact edX support for additional suggestions.

+
+ ); +} diff --git a/src/id-verification/IdVerificationContext.jsx b/src/id-verification/IdVerificationContext.jsx new file mode 100644 index 0000000..187ae1a --- /dev/null +++ b/src/id-verification/IdVerificationContext.jsx @@ -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 ; + } + + if (!existingIdVerification.canVerify) { + const status = existingIdVerification.status; + return ( +
+

Identity Verification

+ {status === 'pending' || status == 'approved' + ?

+ 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). +

+ :

+ You cannot verify your identity at this time. +

+ } + + Return to Your Dashboard + +
+ ); + } + + return ( + + {children} + + ); +} +IdVerificationContextProvider.propTypes = { + children: PropTypes.node, +}; +IdVerificationContextProvider.defaultProps = { + children: undefined, +}; + +export { + IdVerificationContext, + IdVerificationContextProvider, + MEDIA_ACCESS, +}; diff --git a/src/id-verification/IdVerificationPage.jsx b/src/id-verification/IdVerificationPage.jsx new file mode 100644 index 0000000..77e4850 --- /dev/null +++ b/src/id-verification/IdVerificationPage.jsx @@ -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 ( +
+
+
+ + + + + + + + + + + + + + + + +
+
+ +
+
+ + + +
Why does edX need my photo?
+

We use your verification photos to confirm your identity and ensure the validity of your certificate.

+
What does edX do with this photo?
+

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.

+
+ )} + onClose={() => setIsModalOpen(false)} + /> + + + ); +} +export default connect(idVerificationSelector, { +})(injectIntl(IdVerificationPage)); + diff --git a/src/id-verification/ImageFileUpload.jsx b/src/id-verification/ImageFileUpload.jsx new file mode 100644 index 0000000..00d7941 --- /dev/null +++ b/src/id-verification/ImageFileUpload.jsx @@ -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 ( + + ); +} + +ImageFileUpload.propTypes = { + onFileChange: PropTypes.func.isRequired, +}; diff --git a/src/id-verification/ImagePreview.jsx b/src/id-verification/ImagePreview.jsx new file mode 100644 index 0000000..ad9d6f8 --- /dev/null +++ b/src/id-verification/ImagePreview.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default function ImagePreview({ src, alt, id }) { + return ( +
+ + {alt} + +
+ ); +} + +ImagePreview.propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string.isRequired, + id: PropTypes.string, +}; diff --git a/src/id-verification/_id-verification.scss b/src/id-verification/_id-verification.scss new file mode 100644 index 0000000..b5d7f88 --- /dev/null +++ b/src/id-verification/_id-verification.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/id-verification/data/camera-shutter.base64.json b/src/id-verification/data/camera-shutter.base64.json new file mode 100644 index 0000000..50112c1 --- /dev/null +++ b/src/id-verification/data/camera-shutter.base64.json @@ -0,0 +1,3 @@ +{ + "base64": "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Oau1+DczWWohH8L0eh/nWTbo8/OvyZ/ccZuEf/+Rn/l/L/M//+ZEqKpH+ZFToqJ+iqi+i/yoi+ip9yhgYMh27OZUWpgoIEd1ITFaxUllRQWcDAaskNCYwCgJAlEQaBsJgsBQSlREGgbCYLBUBAUSiINB2dKnUZK3kSJ1948Y9LNCn/I9c763Wf1AVct8rUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5REtg5yOQ+/ACAYIkLiWBAEIy5G6UjKQIBcAPWAH5ARjShVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVACCU025+ZGfywyOy3//ksyP+XMmstIyNWCggQNHI1ls////WWRyMmVgYME6GTLJZ///2WWxyNWWSoZGrWyzL/9WVqCBOhkyhgYMI6GrBQUqFpoVFP9bDILCzOLiv/rFRYk//qF+LCrDKm/4o2sVFtbP6hdmLCsyoXUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5Rkjg/ygl8ruCAaQiiABWAAAAAAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjk5LjVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+5RkUY/wAABpAAAACAAADSAAAAEAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=" +} \ No newline at end of file diff --git a/src/id-verification/data/selectors.js b/src/id-verification/data/selectors.js new file mode 100644 index 0000000..9c94d05 --- /dev/null +++ b/src/id-verification/data/selectors.js @@ -0,0 +1,3 @@ +export const storeName = 'idVerifiction'; + +export const idVerificationSelector = state => ({ ...state[storeName] }); diff --git a/src/id-verification/data/service.js b/src/id-verification/data/service.js new file mode 100644 index 0000000..41dec84 --- /dev/null +++ b/src/id-verification/data/service.js @@ -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? + } +} diff --git a/src/id-verification/getUserMediaShim.js b/src/id-verification/getUserMediaShim.js new file mode 100644 index 0000000..2cc0169 --- /dev/null +++ b/src/id-verification/getUserMediaShim.js @@ -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 }; diff --git a/src/id-verification/index.js b/src/id-verification/index.js new file mode 100644 index 0000000..fea8a5d --- /dev/null +++ b/src/id-verification/index.js @@ -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'; diff --git a/src/id-verification/panels/BasePanel.jsx b/src/id-verification/panels/BasePanel.jsx new file mode 100644 index 0000000..713003f --- /dev/null +++ b/src/id-verification/panels/BasePanel.jsx @@ -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 ; + } + + return ( +
+

{title}

+ {children} +
+ ); +} + +BasePanel.propTypes = { + children: PropTypes.node.isRequired, + focusOnMount: PropTypes.bool, + name: PropTypes.string.isRequired, + title: PropTypes.node.isRequired, +}; + +BasePanel.defaultProps = { + focusOnMount: true, +}; diff --git a/src/id-verification/panels/GetNameIdPanel.jsx b/src/id-verification/panels/GetNameIdPanel.jsx new file mode 100644 index 0000000..9516f4a --- /dev/null +++ b/src/id-verification/panels/GetNameIdPanel.jsx @@ -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 ( + +

Please check the Account Name below to ensure it matches the name on your ID. If not, click "Edit".

+ +
+ Please Note: any edit to your name will be saved to your account and can be reviewed on Account Settings. +
+ +
+ +
+ setIdPhotoName(e.target.value)} + /> + {!isEditing && ( + + )} +
+
+ + +
+ + {isEditing ? 'Save' : 'Next'} + +
+
+ ); +} diff --git a/src/id-verification/panels/IdContextPanel.jsx b/src/id-verification/panels/IdContextPanel.jsx new file mode 100644 index 0000000..efce411 --- /dev/null +++ b/src/id-verification/panels/IdContextPanel.jsx @@ -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 ( + +

Next you'll need an eligible photo, make sure that:

+
+
+
Photo Tips
+

To take a successful photo, make sure that:

+
    +
  • Your ID is well-lit.
  • +
  • Ensure that you can see your photo and clearly read your name.
  • +
+
+
+
+ + Next + +
+
+ ); +} diff --git a/src/id-verification/panels/PortraitPhotoContextPanel.jsx b/src/id-verification/panels/PortraitPhotoContextPanel.jsx new file mode 100644 index 0000000..9d243e7 --- /dev/null +++ b/src/id-verification/panels/PortraitPhotoContextPanel.jsx @@ -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 ( + +

Next, we'll need you to take a photo of your face. Please review the helpful tips below.

+
+
+
Photo Tips
+

To take a successful photo, make sure that:

+
    +
  • Your face is well-lit.
  • +
  • Your entire face fits inside the frame.
  • +
+
+
+
+ + Next + +
+
+ ); +} diff --git a/src/id-verification/panels/RequestCameraAccessPanel.jsx b/src/id-verification/panels/RequestCameraAccessPanel.jsx new file mode 100644 index 0000000..ae811fc --- /dev/null +++ b/src/id-verification/panels/RequestCameraAccessPanel.jsx @@ -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 ( + + {mediaAccess === MEDIA_ACCESS.PENDING && ( +
+

In order to take a photo using your webcam, you may receive a browser prompt for access to your camera. Please make sure to click "Allow"

+ +
+ + + + + Having problems? + + + + + Skip and upload image files instead + + + +
+
+ )} + + {mediaAccess === MEDIA_ACCESS.GRANTED && ( +
+

+ Looks like your camera is working and ready. +

+
+ + Next + +
+
+ )} + + {[MEDIA_ACCESS.UNSUPPORTED, MEDIA_ACCESS.DENIED].includes(mediaAccess) && ( +
+

+ It looks like we're unable to access your camera. You will need to upload + image files of you and your photo id. +

+
+ + Next + +
+
+ )} + +
+ ); +} diff --git a/src/id-verification/panels/ReviewRequirementsPanel.jsx b/src/id-verification/panels/ReviewRequirementsPanel.jsx new file mode 100644 index 0000000..1a8512a --- /dev/null +++ b/src/id-verification/panels/ReviewRequirementsPanel.jsx @@ -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 ( + +

+ In order to complete Photo Verification online, you will need the following +

+
+
+
Device with Camera
+

You need a device that has a camera. If you receive a browser prompt for access to your camera, please make sure to click Allow.

+
+
+
+
+
Photo Identification
+

You need a valid ID that contains your full name and photo.

+
+
+

Privacy Information

+
Why does edX need my photo?
+

We use your verification photos to confirm your identity and ensure the validity of your certificate.

+
What does edX do with this photo?
+

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.

+ +
+ + Next + +
+
+ ); +} diff --git a/src/id-verification/panels/SubmittedPanel.jsx b/src/id-verification/panels/SubmittedPanel.jsx new file mode 100644 index 0000000..634d9ba --- /dev/null +++ b/src/id-verification/panels/SubmittedPanel.jsx @@ -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 ( + +

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.

+ Return to Your Dashboard +
+ ); +} diff --git a/src/id-verification/panels/SummaryPanel.jsx b/src/id-verification/panels/SummaryPanel.jsx new file mode 100644 index 0000000..3ff8476 --- /dev/null +++ b/src/id-verification/panels/SummaryPanel.jsx @@ -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 ( + + ); + } + + return ( + +

Make sure we can verify your identity with the photos and information you have provided.

+
+
+ + + + Retake Portrait Photo + +
+
+ + + + Retake ID Photo + +
+
+
+ +
+ {}} + /> + + + Edit + +
+
+ +
+ ); +} diff --git a/src/id-verification/panels/TakeIdPhotoPanel.jsx b/src/id-verification/panels/TakeIdPhotoPanel.jsx new file mode 100644 index 0000000..3932e0c --- /dev/null +++ b/src/id-verification/panels/TakeIdPhotoPanel.jsx @@ -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 ( + +
+ {idPhotoFile && !shouldUseCamera && } + + {shouldUseCamera ? ( +
+

When your ID is in position, use the Take Photo button below to take your photo.

+ +
+ ) : ( +
+

Please upload a ID photo. Ensure the entire ID fits inside the frame and is well-lit. (Supported formats: .jpg, .jpeg, .png)

+ +
+ )} +
+
+ + Next + +
+ {shouldUseCamera && } +
+ ); +} diff --git a/src/id-verification/panels/TakePortraitPhotoPanel.jsx b/src/id-verification/panels/TakePortraitPhotoPanel.jsx new file mode 100644 index 0000000..f5c6846 --- /dev/null +++ b/src/id-verification/panels/TakePortraitPhotoPanel.jsx @@ -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 ( + +
+ {facePhotoFile && !shouldUseCamera && } + + {shouldUseCamera ? ( +
+

When your face is in position, use the Take Photo button below to take your photo.

+ +
+ ) : ( +
+

Please upload a portrait photo. Ensure your entire face fits inside the frame and is well-lit. (Supported formats: .jpg, .jpeg, .png)

+ +
+ )} +
+
+ + Next + +
+ {shouldUseCamera && } +
+ ); +} diff --git a/src/id-verification/routing-utilities.js b/src/id-verification/routing-utilities.js new file mode 100644 index 0000000..96da242 --- /dev/null +++ b/src/id-verification/routing-utilities.js @@ -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; +}; diff --git a/src/index.jsx b/src/index.jsx index f1803a9..42dda62 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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 }) => ( -
+
-
- {children} -
+
+ {children} +
); @@ -33,9 +34,12 @@ subscribe(APP_READY, () => { - - - + + + + + + , diff --git a/src/index.scss b/src/index.scss index 29bfa7e..165d90a 100755 --- a/src/index.scss +++ b/src/index.scss @@ -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;