diff --git a/.circleci/config.yml b/.circleci/config.yml index 1ed1928599..e783858018 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -363,7 +363,7 @@ workflows: filters: branches: only: - - reskin-profile-settings + - feature/dice-setup # This is beta env for production soft releases - "build-prod-beta": context : org-global diff --git a/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap b/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap index 608ab744be..3b2db9879e 100644 --- a/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap +++ b/__tests__/shared/components/Settings/Account/__snapshots__/index.jsx.snap @@ -708,6 +708,702 @@ exports[`renders account setting page correctly 1`] = ` updatePassword={[Function]} updateProfile={[Function]} /> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/account/security/dicelogo.png b/src/assets/images/account/security/dicelogo.png new file mode 100644 index 0000000000..723b63b2e6 Binary files /dev/null and b/src/assets/images/account/security/dicelogo.png differ diff --git a/src/assets/images/account/security/dicelogobig.png b/src/assets/images/account/security/dicelogobig.png new file mode 100644 index 0000000000..b18935081d Binary files /dev/null and b/src/assets/images/account/security/dicelogobig.png differ diff --git a/src/assets/images/account/security/dicelogosmall.png b/src/assets/images/account/security/dicelogosmall.png new file mode 100644 index 0000000000..c2be4e4a80 Binary files /dev/null and b/src/assets/images/account/security/dicelogosmall.png differ diff --git a/src/assets/images/account/security/google-play.png b/src/assets/images/account/security/google-play.png new file mode 100644 index 0000000000..5311fbf031 Binary files /dev/null and b/src/assets/images/account/security/google-play.png differ diff --git a/src/assets/images/account/security/green-close.svg b/src/assets/images/account/security/green-close.svg new file mode 100644 index 0000000000..d3ff15c89d --- /dev/null +++ b/src/assets/images/account/security/green-close.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/images/account/security/mfa.svg b/src/assets/images/account/security/mfa.svg new file mode 100644 index 0000000000..33b5ab0cc7 --- /dev/null +++ b/src/assets/images/account/security/mfa.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/images/account/security/unsuccessful.svg b/src/assets/images/account/security/unsuccessful.svg new file mode 100644 index 0000000000..29ae859bd8 --- /dev/null +++ b/src/assets/images/account/security/unsuccessful.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/shared/actions/mfa.js b/src/shared/actions/mfa.js new file mode 100644 index 0000000000..6551e28ec9 --- /dev/null +++ b/src/shared/actions/mfa.js @@ -0,0 +1,90 @@ +import { createActions } from 'redux-actions'; +import { getService } from '../services/mfa'; + +/** + * @static + * @desc Creates an action that get user 2fa settings + * @param {String} userId Operation User id. + * @param {String} tokenV3 v3 auth token. + * @return {Action} + */ +async function getUser2faDone(userId, tokenV3) { + return getService(tokenV3).getUser2fa(userId); +} + +/** + * @static + * @desc Creates an action that signals beginning of updating user 2fa settings. + * @return {Action} + */ +function updateUser2faInit() { } + +/** + * @static + * @desc Update user 2fa settings + * @param {Number} userId User id. + * @param {Boolean} mfaEnabled 2fa flag. + * @return {Action} + */ +async function updateUser2faDone(userId, mfaEnabled, tokenV3) { + return getService(tokenV3).updateUser2fa(userId, mfaEnabled); +} + +/** + * @static + * @desc Update user dice settings + * @param {Number} userId User id. + * @param {Boolean} diceEnabled dice flag. + * @return {Action} + */ +async function updateUserDiceDone(userId, diceEnabled, tokenV3) { + return getService(tokenV3).updateUserDice(userId, diceEnabled); +} + +/** + * @static + * @desc Creates an action that signals beginning of getting new dice connection + * @return {Action} + */ +function getNewDiceConnectionInit() { } + +/** + * @static + * @desc Get new Dice connection + * @param {Number} userId User id. + * @return {Action} + */ +async function getNewDiceConnectionDone(userId, tokenV3) { + return getService(tokenV3).getNewDiceConnection(userId); +} + +/** + * @static + * @desc Creates an action that signals beginning of getting dice connection + * @return {Action} + */ +function getDiceConnectionInit() { } + +/** + * @static + * @desc Get Dice connection + * @param {Number} userId User id. + * @param {Number} connectionId User id. + * @return {Action} + */ +async function getDiceConnectionDone(userId, connectionId, tokenV3) { + return getService(tokenV3).getDiceConnection(userId, connectionId); +} + +export default createActions({ + USERMFA: { + GET_USER2FA_DONE: getUser2faDone, + UPDATE_USER2FA_INIT: updateUser2faInit, + UPDATE_USER2FA_DONE: updateUser2faDone, + UPDATE_USER_DICE_DONE: updateUserDiceDone, + GET_NEW_DICE_CONNECTION_INIT: getNewDiceConnectionInit, + GET_NEW_DICE_CONNECTION_DONE: getNewDiceConnectionDone, + GET_DICE_CONNECTION_INIT: getDiceConnectionInit, + GET_DICE_CONNECTION_DONE: getDiceConnectionDone, + }, +}); diff --git a/src/shared/components/Settings/Account/MyAccount/styles.scss b/src/shared/components/Settings/Account/MyAccount/styles.scss index cc6afae006..ba61547185 100644 --- a/src/shared/components/Settings/Account/MyAccount/styles.scss +++ b/src/shared/components/Settings/Account/MyAccount/styles.scss @@ -349,7 +349,7 @@ padding: $pad-xxxxl; background-color: $color-tc-white; border-radius: 4px; - margin: $margin-sm 0 0; + margin: $margin-sm 0 $margin-xxxxl 0; .password-form { display: flex; diff --git a/src/shared/components/Settings/Account/Security/Modal/index.jsx b/src/shared/components/Settings/Account/Security/Modal/index.jsx new file mode 100644 index 0000000000..3e33c982e9 --- /dev/null +++ b/src/shared/components/Settings/Account/Security/Modal/index.jsx @@ -0,0 +1,80 @@ +import React, { useEffect } from 'react'; +import PT from 'prop-types'; +import DiceImage from 'assets/images/account/security/dicelogosmall.png'; +import CloseButton from 'assets/images/account/security/green-close.svg'; + +import './style.scss'; + +export default function DiceModal({ + children, onCancel, leftButtonName, leftButtonDisabled, leftButtonClick, + rightButtonName, rightButtonDisabled, rightButtonClick, rightButtonHide, +}) { + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = 'unset'; }; + }, []); + + return ( + ( + +
event.stopPropagation()} + > +
+
+ diceid +
+
DICE ID authenticator setup
+ +
+
+
+ {children} +
+
+
+
+ {leftButtonName} +
+ {!rightButtonHide && ( +
+ {rightButtonName} +
+ )} +
+
+
+ + ) + ); +} +DiceModal.defaultProps = { + children: null, + leftButtonDisabled: false, + rightButtonDisabled: false, + rightButtonHide: false, +}; +DiceModal.propTypes = { + children: PT.node, + onCancel: PT.func.isRequired, + leftButtonName: PT.string.isRequired, + leftButtonDisabled: PT.bool, + leftButtonClick: PT.func.isRequired, + rightButtonName: PT.string.isRequired, + rightButtonDisabled: PT.bool, + rightButtonClick: PT.func.isRequired, + rightButtonHide: PT.bool, +}; diff --git a/src/shared/components/Settings/Account/Security/Modal/style.scss b/src/shared/components/Settings/Account/Security/Modal/style.scss new file mode 100644 index 0000000000..1f972a52ec --- /dev/null +++ b/src/shared/components/Settings/Account/Security/Modal/style.scss @@ -0,0 +1,162 @@ +@import "../../../style"; +@import "~styles/mixins"; + +.overlay { + background: #0c0c0c; + border: none; + height: 100%; + left: 0; + opacity: 0.85; + outline: none; + position: fixed; + top: 0; + width: 100%; + z-index: 950; +} + +.container { + background: #fff; + position: fixed; + max-width: 1000px; + height: 752px; + max-height: 99%; + top: 50%; + left: 50%; + padding: 32px; + box-shadow: 0 0 1px 5px rgba(0, 0, 0, 0.2); + border-radius: 8px; + overflow: auto; + transform: translate(-50%, -50%); + z-index: 951; + display: flex; + flex-direction: column; + align-items: flex-start; + + .header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 24px; + + .icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 150px; + height: 40px; + background: #fff; + border-radius: 4px; + margin-right: $margin-md; + + img { + display: block; + } + } + + .title { + @include barlow-semi-bold; + + flex: 1; + font-size: 22px; + line-height: 26px; + color: $tco-black; + text-transform: uppercase; + } + + .close-button { + cursor: pointer; + height: 15px; + width: 15px; + } + } + + .body { + flex: 1; + width: 100%; + display: flex; + flex-direction: column; + margin-bottom: 24px; + } + + .divider { + width: 100%; + min-height: 2px; + background-color: #e9e9e9; + border-radius: 1px; + margin-bottom: 24px; + } + + .footer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + + .left-button { + @include roboto-bold; + + text-align: center; + margin: 0; + height: 48px; + font-size: 16px; + line-height: 21px; + letter-spacing: 0.008em; + text-transform: uppercase; + color: $color-turq-160; + padding: 12px 24px; + background: $tc-white; + border: 2px solid $color-turq-160; + border-radius: 50px; + cursor: pointer; + + &:focus { + outline: none; + } + + &:focus-visible { + outline: none; + } + + &.disabled { + background: #f4f4f4; + color: #767676; + border: none; + line-height: 24px; + pointer-events: none; + } + } + + .right-button { + @include roboto-bold; + + text-align: center; + margin: 0; + height: 48px; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.008em; + text-transform: uppercase; + color: $tc-white; + padding: 12px 24px; + background: $color-turq-160; + border-radius: 50px; + cursor: pointer; + + &:focus { + outline: none; + } + + &:focus-visible { + outline: none; + } + + &.disabled { + background: #f4f4f4; + color: #767676; + pointer-events: none; + } + } + } +} diff --git a/src/shared/components/Settings/Account/Security/VerificationListener/index.jsx b/src/shared/components/Settings/Account/Security/VerificationListener/index.jsx new file mode 100644 index 0000000000..26e0ee9e54 --- /dev/null +++ b/src/shared/components/Settings/Account/Security/VerificationListener/index.jsx @@ -0,0 +1,32 @@ +import { useEffect, useCallback } from 'react'; +import PT from 'prop-types'; + +export default function VerificationListener({ + event, callback, origin, type, onProcessing, startType, +}) { + const messageHandler = useCallback((e) => { + if (e.origin === origin && e.data && e.data.type) { + if (e.data.type === startType) { + onProcessing(); + } else if (e.data.type === type) { + callback(e.data); + } + } + }, [origin, type, startType]); + + useEffect(() => { + window.addEventListener(event, messageHandler); + return () => window.removeEventListener(event, messageHandler); + }, [event, messageHandler]); + + return false; +} + +VerificationListener.propTypes = { + event: PT.string.isRequired, + callback: PT.func.isRequired, + origin: PT.string.isRequired, + type: PT.string.isRequired, + onProcessing: PT.func.isRequired, + startType: PT.string.isRequired, +}; diff --git a/src/shared/components/Settings/Account/Security/index.jsx b/src/shared/components/Settings/Account/Security/index.jsx new file mode 100644 index 0000000000..cf24894066 --- /dev/null +++ b/src/shared/components/Settings/Account/Security/index.jsx @@ -0,0 +1,413 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PT from 'prop-types'; +import _ from 'lodash'; +import { config } from 'topcoder-react-utils'; +import QRCode from 'react-qr-code'; +import { SettingBannerV2 as Collapse } from 'components/Settings/SettingsBanner'; +import MfaImage from 'assets/images/account/security/mfa.svg'; +import DiceLogo from 'assets/images/account/security/dicelogo.png'; +import DiceLogoBig from 'assets/images/account/security/dicelogobig.png'; +import GooglePlay from 'assets/images/account/security/google-play.png'; +import AppleStore from 'assets/images/account/security/apple-store.svg'; +import UnsuccessfulIcon from 'assets/images/account/security/unsuccessful.svg'; +import Modal from './Modal'; +import VerificationListener from './VerificationListener'; + + +import './styles.scss'; + +export default function Security({ + usermfa, updateUser2fa, updateUserDice, getNewDiceConnection, + getDiceConnection, tokenV3, handle, emailAddress, +}) { + const [setupStep, setSetupStep] = useState(-1); + const [isConnVerifyRunning, setIsConnVerifyRunning] = useState(false); + const [connVerifyCounter, setConnVerifyCounter] = useState(0); + const [isVerificationProcessing, setIsVerificationProcessing] = useState(false); + const diceVerifyUrl = config.DICE_VERIFY_URL; + const useInterval = (callback, delay) => { + const savedCallback = useRef(); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // eslint-disable-next-line consistent-return + useEffect(() => { + function tick() { + savedCallback.current(); + } + if (delay !== null) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + setConnVerifyCounter(0); + }, [delay]); + }; + + const getMfaOption = () => { + const mfaEnabled = _.get(usermfa, 'user2fa.mfaEnabled'); + if (mfaEnabled) return true; + return false; + }; + + const getDiceOption = () => { + const diceEnabled = _.get(usermfa, 'user2fa.diceEnabled'); + if (diceEnabled) return true; + return false; + }; + + const mfaChecked = getMfaOption(); + const diceChecked = getDiceOption(); + const userId = _.get(usermfa, 'user2fa.userId'); + const diceConnection = _.get(usermfa, 'diceConnection'); + + const onUpdateMfaOption = () => { + if (usermfa.updatingUser2fa) { + return; + } + updateUser2fa(userId, !mfaChecked, tokenV3); + }; + + const goToConnection = () => { + if (mfaChecked && !usermfa.gettingNewDiceConnection) { + getNewDiceConnection(userId, tokenV3); + } + setSetupStep(1); + setIsConnVerifyRunning(true); + }; + + const getConnectionAccepted = () => { + if (diceConnection.accepted) return true; + return false; + }; + + const openSetup = () => { + setSetupStep(0); + }; + + const closeSetup = () => { + setSetupStep(-1); + setIsVerificationProcessing(false); + }; + + const verifyConnection = () => { + if (getConnectionAccepted() || usermfa.diceConnectionError) { + setIsConnVerifyRunning(false); + } else if (!usermfa.gettingDiceConnection && diceConnection.id) { + if (connVerifyCounter >= 36) { + closeSetup(); + } else { + getDiceConnection(userId, diceConnection.id, tokenV3); + setConnVerifyCounter(connVerifyCounter + 1); + } + } + }; + + const goToVerification = () => { + if (!getConnectionAccepted()) { + return; + } + setSetupStep(2); + }; + + const verificationCallback = (data) => { + if (data.success) { + const userEmail = _.get(data, 'user.profile.Email'); + if (!_.isUndefined(userEmail) && _.lowerCase(userEmail) === _.lowerCase(emailAddress)) { + updateUserDice(userId, true, tokenV3); + setSetupStep(3); + } else { + setSetupStep(4); + } + } else { + setSetupStep(4); + } + }; + + const onStartProcessing = () => { + setIsVerificationProcessing(true); + }; + + useInterval(verifyConnection, setupStep === 1 && isConnVerifyRunning ? 5000 : null); + + const getVerificationStepTitle = () => { + if (isVerificationProcessing) { + return 'Processing...'; + } + return 'STEP 3 OF 3'; + }; + + const getVerificationStepText = () => { + if (isVerificationProcessing) { + return 'Please wait while your credentials are validated.'; + } + return 'Scan the following DICE ID QR Code in your DICE ID mobile application to confirm your credential.'; + }; + + const setupStepNodes = [ + +
+
+ STEP 1 OF 3 +
+
+ First, please download the DICE ID App from the + Google Play Store or the iOS App Store. +
+
+
+ Google Play Store + +
+
+ + +
+
+
+ After you have downloaded and installed the mobile app, + make sure to complete the configuration process. + When ready, click next below. +
+
+
, + +
+
+ STEP 2 OF 3 +
+
+ Scan the following DICE ID QR Code in your DICE ID mobile application. +
+
+ {diceConnection.connection + ? + : 'Loading'} +
+
+ Once the connection is established, the service will offer you a Verifiable Credential. +
Press the ACCEPT button in your DICE ID App. +
If you DECLINE the invitation, please try again after 5 minutes. +
+
+
, + {}} + rightButtonHide + > +
+
+ {getVerificationStepTitle()} +
+
+ {getVerificationStepText()} +
+