diff --git a/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap b/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap index 93507dd74..704184c29 100644 --- a/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap +++ b/__tests__/reducers/__snapshots__/create-otp-reducer.js.snap @@ -8,6 +8,9 @@ Object { "debouncePlanTimeMs": 0, "homeTimezone": "America/Los_Angeles", "language": Object {}, + "phoneFormatOptions": Object { + "countryCode": "US", + }, "realtimeEffectsDisplayThreshold": 120, "routingTypes": Array [], "stopViewer": Object { diff --git a/example-config.yml b/example-config.yml index 719aaf23b..85722e6d8 100644 --- a/example-config.yml +++ b/example-config.yml @@ -140,3 +140,8 @@ itinerary: # modes: # - WALK # - BICYCLE + +### If using OTP Middleware, you can define the optional phone number options below. +# phoneFormatOptions: +# # ISO 2-letter country code for phone number formats (defaults to 'US') +# countryCode: US diff --git a/lib/actions/user.js b/lib/actions/user.js index 4eb46f5a7..294d895d5 100644 --- a/lib/actions/user.js +++ b/lib/actions/user.js @@ -1,19 +1,17 @@ import { createAction } from 'redux-actions' import { routeTo } from './ui' -import { - addTrip, - addUser, - deleteTrip, - fetchUser, - getTrips, - updateTrip, - updateUser -} from '../util/middleware' +import { secureFetch } from '../util/middleware' import { isNewUser } from '../util/user' +// Middleware API paths. +const API_MONITORTRIP_PATH = '/api/secure/monitoredtrip' +const API_OTPUSER_PATH = '/api/secure/user' +const API_OTPUSER_VERIFYSMS_PATH = '/verify_sms' + const setCurrentUser = createAction('SET_CURRENT_USER') const setCurrentUserMonitoredTrips = createAction('SET_CURRENT_USER_MONITORED_TRIPS') +const setLastPhoneSmsRequest = createAction('SET_LAST_PHONE_SMS_REQUEST') export const setPathBeforeSignIn = createAction('SET_PATH_BEFORE_SIGNIN') function createNewUser (auth0User) { @@ -28,6 +26,29 @@ function createNewUser (auth0User) { } } +/** + * Extracts accessToken, loggedInUser from the redux state, + * and apiBaseUrl, apiKey from the middleware configuration. + * If the middleware configuration does not exist, throws an error. + */ +function getMiddlewareVariables (state) { + const { otp, user } = state + const { otp_middleware: otpMiddleware = null } = otp.config.persistence + + if (otpMiddleware) { + const { accessToken, loggedInUser } = user + const { apiBaseUrl, apiKey } = otpMiddleware + return { + accessToken, + apiBaseUrl, + apiKey, + loggedInUser + } + } else { + throw new Error('This action requires config.yml#persistence#otp_middleware to be defined.') + } +} + /** * Fetches the saved/monitored trips for a user. * We use the accessToken to fetch the data regardless of @@ -35,60 +56,57 @@ function createNewUser (auth0User) { */ export function fetchUserMonitoredTrips (accessToken) { return async function (dispatch, getState) { - const { otp } = getState() - const { otp_middleware: otpMiddleware = null } = otp.config.persistence + const { apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) + const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}` - if (otpMiddleware) { - const { data: trips, status: fetchStatus } = await getTrips(otpMiddleware, accessToken) - if (fetchStatus === 'success') { - dispatch(setCurrentUserMonitoredTrips(trips.data)) - } + const { data: trips, status } = await secureFetch(requestUrl, accessToken, apiKey, 'GET') + if (status === 'success') { + dispatch(setCurrentUserMonitoredTrips(trips.data)) } } } /** - * Fetches user preferences to state.user, or set initial values under state.user if no user has been loaded. + * Fetches user preferences to state.user, + * or set initial values under state.user if no user has been loaded. */ export function fetchOrInitializeUser (auth) { return async function (dispatch, getState) { - const { otp } = getState() - const { otp_middleware: otpMiddleware = null } = otp.config.persistence - - if (otpMiddleware) { - const { accessToken, user: authUser } = auth - const { data: user, status: fetchUserStatus } = await fetchUser(otpMiddleware, accessToken) - - // Beware! On AWS API gateway, if a user is not found in the middleware - // (e.g. they just created their Auth0 password but have not completed the account setup form yet), - // the call above will return, for example: - // { - // status: 'success', - // data: { - // "result": "ERR", - // "message": "No user with id=000000 found.", - // "code": 404, - // "detail": null - // } - // } - // - // The same call to a middleware instance that is not behind an API gateway - // will return: - // { - // status: 'error', - // message: 'Error get-ing user...' - // } - // TODO: Improve AWS response. - - const isNewAccount = fetchUserStatus === 'error' || (user && user.result === 'ERR') - if (!isNewAccount) { - // Load user's monitored trips before setting the user state. - await dispatch(fetchUserMonitoredTrips(accessToken)) - - dispatch(setCurrentUser({ accessToken, user })) - } else { - dispatch(setCurrentUser({ accessToken, user: createNewUser(authUser) })) - } + const { apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) + const { accessToken, user: authUser } = auth + const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/fromtoken` + + const { data: user, status } = await secureFetch(requestUrl, accessToken, apiKey) + + // Beware! On AWS API gateway, if a user is not found in the middleware + // (e.g. they just created their Auth0 password but have not completed the account setup form yet), + // the call above will return, for example: + // { + // status: 'success', + // data: { + // "result": "ERR", + // "message": "No user with id=000000 found.", + // "code": 404, + // "detail": null + // } + // } + // + // The same call to a middleware instance that is not behind an API gateway + // will return: + // { + // status: 'error', + // message: 'Error get-ing user...' + // } + // TODO: Improve AWS response. + + const isNewAccount = status === 'error' || (user && user.result === 'ERR') + if (!isNewAccount) { + // Load user's monitored trips before setting the user state. + await dispatch(fetchUserMonitoredTrips(accessToken)) + + dispatch(setCurrentUser({ accessToken, user })) + } else { + dispatch(setCurrentUser({ accessToken, user: createNewUser(authUser) })) } } } @@ -96,33 +114,39 @@ export function fetchOrInitializeUser (auth) { /** * Updates (or creates) a user entry in the middleware, * then, if that was successful, updates the redux state with that user. + * @param userData the user entry to persist. + * @param silentOnSuccess true to suppress the confirmation if the operation is successful (e.g. immediately after user accepts the terms). */ -export function createOrUpdateUser (userData) { +export function createOrUpdateUser (userData, silentOnSuccess = false) { return async function (dispatch, getState) { - const { otp, user } = getState() - const { otp_middleware: otpMiddleware = null } = otp.config.persistence + const { accessToken, apiBaseUrl, apiKey, loggedInUser } = getMiddlewareVariables(getState()) + const { id } = userData // Middleware ID, NOT auth0 (or similar) id. + let requestUrl, method - if (otpMiddleware) { - const { accessToken, loggedInUser } = user + // Determine URL and method to use. + if (isNewUser(loggedInUser)) { + requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}` + method = 'POST' + } else { + requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/${id}` + method = 'PUT' + } - let result - if (isNewUser(loggedInUser)) { - result = await addUser(otpMiddleware, accessToken, userData) - } else { - result = await updateUser(otpMiddleware, accessToken, userData) - } + const { data, message, status } = await secureFetch(requestUrl, accessToken, apiKey, method, { + body: JSON.stringify(userData) + }) - // TODO: improve the UI feedback messages for this. - if (result.status === 'success' && result.data) { + // TODO: improve the UI feedback messages for this. + if (status === 'success' && data) { + if (!silentOnSuccess) { alert('Your preferences have been saved.') - - // Update application state with the user entry as saved - // (as returned) by the middleware. - const userData = result.data - dispatch(setCurrentUser({ accessToken, user: userData })) - } else { - alert(`An error was encountered:\n${JSON.stringify(result)}`) } + + // Update application state with the user entry as saved + // (as returned) by the middleware. + dispatch(setCurrentUser({ accessToken, user: data })) + } else { + alert(`An error was encountered:\n${JSON.stringify(message)}`) } } } @@ -134,56 +158,122 @@ export function createOrUpdateUser (userData) { */ export function createOrUpdateUserMonitoredTrip (tripData, isNew, silentOnSuccess) { return async function (dispatch, getState) { - const { otp, user } = getState() - const { otp_middleware: otpMiddleware = null } = otp.config.persistence + const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) + const { id } = tripData + let requestUrl, method - if (otpMiddleware) { - const { accessToken } = user + // Determine URL and method to use. + if (isNew) { + requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}` + method = 'POST' + } else { + requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}/${id}` + method = 'PUT' + } - let result - if (isNew) { - result = await addTrip(otpMiddleware, accessToken, tripData) - } else { - result = await updateTrip(otpMiddleware, accessToken, tripData) + const { data, message, status } = await secureFetch(requestUrl, accessToken, apiKey, method, { + body: JSON.stringify(tripData) + }) + + // TODO: improve the UI feedback messages for this. + if (status === 'success' && data) { + if (!silentOnSuccess) { + alert('Your preferences have been saved.') } - // TODO: improve the UI feedback messages for this. - if (result.status === 'success' && result.data) { - if (!silentOnSuccess) { - alert('Your preferences have been saved.') - } + // Reload user's monitored trips after add/update. + await dispatch(fetchUserMonitoredTrips(accessToken)) + + // Finally, navigate to the saved trips page. + dispatch(routeTo('/savedtrips')) + } else { + alert(`An error was encountered:\n${JSON.stringify(message)}`) + } + } +} - // Reload user's monitored trips after add/update. - await dispatch(fetchUserMonitoredTrips(accessToken)) +/** + * Deletes a logged-in user's monitored trip, + * then, if that was successful, refreshes the redux monitoredTrips state. + */ +export function deleteUserMonitoredTrip (tripId) { + return async function (dispatch, getState) { + const { accessToken, apiBaseUrl, apiKey } = getMiddlewareVariables(getState()) + const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}/${tripId}` - // Finally, navigate to the saved trips page. - dispatch(routeTo('/savedtrips')) + const { message, status } = await secureFetch(requestUrl, accessToken, apiKey, 'DELETE') + if (status === 'success') { + // Reload user's monitored trips after deletion. + await dispatch(fetchUserMonitoredTrips(accessToken)) + } else { + alert(`An error was encountered:\n${JSON.stringify(message)}`) + } + } +} + +/** + * Requests a verification code via SMS for the logged-in user. + */ +export function requestPhoneVerificationSms (newPhoneNumber) { + return async function (dispatch, getState) { + const state = getState() + const { number, timestamp } = state.user.lastPhoneSmsRequest + const now = new Date() + + // Request a new verification code if we are requesting a different number. + // or enough time has ellapsed since the last request (1 minute?). + // TODO: Should throttling be handled in the middleware? + if (number !== newPhoneNumber || (now - timestamp) >= 60000) { + const { accessToken, apiBaseUrl, apiKey, loggedInUser } = getMiddlewareVariables(state) + const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/${loggedInUser.id}${API_OTPUSER_VERIFYSMS_PATH}/${encodeURIComponent(newPhoneNumber)}` + + const { message, status } = await secureFetch(requestUrl, accessToken, apiKey, 'GET') + + dispatch(setLastPhoneSmsRequest({ + number: newPhoneNumber, + status, + timestamp: now + })) + + if (status === 'success') { + // Refetch user and update application state with new phone number and verification status. + // (This also refetches the user's monitored trip, and that's ok.) + await dispatch(fetchOrInitializeUser({ accessToken })) } else { - alert(`An error was encountered:\n${JSON.stringify(result)}`) + alert(`An error was encountered:\n${JSON.stringify(message)}`) } + } else { + // Alert user if they have been throttled. + // TODO: improve this alert. + alert('A verification SMS was sent to the indicated phone number less than a minute ago. Please try again in a few moments.') } } } /** - * Deletes a logged-in user's monitored trip, - * then, if that was successful, refreshes the redux monitoredTrips state. + * Validate the phone number verification code for the logged-in user. */ -export function deleteUserMonitoredTrip (id) { +export function verifyPhoneNumber (code) { return async function (dispatch, getState) { - const { otp, user } = getState() - const { otp_middleware: otpMiddleware = null } = otp.config.persistence + const { accessToken, apiBaseUrl, apiKey, loggedInUser } = getMiddlewareVariables(getState()) + const requestUrl = `${apiBaseUrl}${API_OTPUSER_PATH}/${loggedInUser.id}${API_OTPUSER_VERIFYSMS_PATH}/${code}` - if (otpMiddleware) { - const { accessToken } = user - const deleteResult = await deleteTrip(otpMiddleware, accessToken, id) + const { data, status } = await secureFetch(requestUrl, accessToken, apiKey, 'POST') - if (deleteResult.status === 'success') { - // Reload user's monitored trips after deletion. - await dispatch(fetchUserMonitoredTrips(accessToken)) + // If the check is successful, status in the returned data will be "approved". + if (status === 'success' && data) { + if (data.status === 'approved') { + // Refetch user and update application state with new phone number and verification status. + // (This also refetches the user's monitored trip, and that's ok.) + dispatch(fetchOrInitializeUser({ accessToken })) } else { - alert(`An error was encountered:\n${JSON.stringify(deleteResult)}`) + // Otherwise, the user entered a wrong/incorrect code. + alert('The code you entered is invalid. Please try again.') } + } else { + // This happens when an error occurs on backend side, especially + // when the code has expired (or was cancelled by Twilio after too many attempts). + alert(`Your phone could not be verified. Perhaps the code you entered has expired. Please request a new code and try again.`) } } } diff --git a/lib/components/user/new-account-wizard.js b/lib/components/user/new-account-wizard.js index 91ecc2b3c..3eb0e906b 100644 --- a/lib/components/user/new-account-wizard.js +++ b/lib/components/user/new-account-wizard.js @@ -1,66 +1,74 @@ -import React from 'react' +import React, { Component } from 'react' import SequentialPaneDisplay from './sequential-pane-display' /** * This component is the new account wizard. */ -const NewAccountWizard = props => { - // The props include Formik props that provide access to the current user data (stored in props.values) - // and to its own blur/change/submit event handlers that automate the state. - // We forward the props to each pane so that their individual controls - // can be wired to be managed by Formik. - const { panes, values: userData } = props +class NewAccountWizard extends Component { + _handleCreateNewUser = () => { + const { + onCreate, // provided by UserAccountScreen + values: userData // provided by Formik + } = this.props - const { - hasConsentedToTerms, - notificationChannel = 'email' - } = userData - - const paneSequence = { - terms: { - disableNext: !hasConsentedToTerms, - nextId: 'notifications', - pane: panes.terms, - props, - title: 'Create a new account' - }, - notifications: { - nextId: notificationChannel === 'sms' ? 'verifyPhone' : 'places', - pane: panes.notifications, - prevId: 'terms', - props, - title: 'Notification preferences' - }, - verifyPhone: { - disableNext: true, // TODO: implement verification. - nextId: 'places', - pane: panes.verifyPhone, - prevId: 'notifications', - props, - title: 'Verify your phone' - }, - places: { - nextId: 'finish', - pane: panes.locations, - prevId: 'notifications', - props, - title: 'Add your locations' - }, - finish: { - pane: panes.finish, - prevId: 'places', - props, - title: 'Account setup complete!' + // Create a user record only if an id is not assigned. + if (!userData.id) { + onCreate(userData) } } - return ( - - ) + render () { + // The props include Formik props that provide access to the current user data (stored in props.values) + // and to its own blur/change/submit event handlers that automate the state. + // We forward the props to each pane so that their individual controls + // can be wired to be managed by Formik. + const props = this.props + const { panes, values: userData } = props + const { + hasConsentedToTerms, + notificationChannel = 'email' + } = userData + + const paneSequence = { + terms: { + disableNext: !hasConsentedToTerms, + nextId: 'notifications', + onNext: this._handleCreateNewUser, + pane: panes.terms, + props, + title: 'Create a new account' + }, + notifications: { + disableNext: notificationChannel === 'sms' && !userData.phoneNumber, + nextId: 'places', + pane: panes.notifications, + prevId: 'terms', + props, + title: 'Notification preferences' + }, + places: { + nextId: 'finish', + pane: panes.locations, + prevId: 'notifications', + props, + title: 'Add your locations' + }, + finish: { + pane: panes.finish, + prevId: 'places', + props, + title: 'Account setup complete!' + } + } + + return ( + + ) + } } export default NewAccountWizard diff --git a/lib/components/user/notification-prefs-pane.js b/lib/components/user/notification-prefs-pane.js index 578572422..3a7ad231f 100644 --- a/lib/components/user/notification-prefs-pane.js +++ b/lib/components/user/notification-prefs-pane.js @@ -1,14 +1,22 @@ -import React from 'react' +import { Field, Formik } from 'formik' +import React, { Component } from 'react' import { + Button, ButtonToolbar, ControlLabel, FormControl, FormGroup, HelpBlock, + Label, ToggleButton, ToggleButtonGroup } from 'react-bootstrap' -import styled from 'styled-components' +import { formatPhoneNumber, isPossiblePhoneNumber } from 'react-phone-number-input' +import Input from 'react-phone-number-input/input' +import styled, { css } from 'styled-components' +import * as yup from 'yup' + +import { isBlank } from '../../util/ui' const allowedNotificationChannels = [ { @@ -28,29 +36,80 @@ const allowedNotificationChannels = [ // Styles // HACK: Preverve container height. const Details = styled.div` - height: 150px; + min-height: 150px; + margin-bottom: 15px; +` +const ControlStrip = styled.div` + > * { + margin-right: 4px; + } +` +const phoneFieldCss = css` + display: inline-block; + vertical-align: middle; + width: 14em; ` +const InlineTextInput = styled(FormControl)` + ${phoneFieldCss} +` +const InlineStatic = styled(FormControl.Static)` + ${phoneFieldCss} +` +const InlinePhoneInput = styled(Input)` + ${phoneFieldCss} +` + +const FlushLink = styled(Button)` + padding-left: 0; + padding-right: 0; +` + +/** + * @param {*} props The props from which to extract the Formik state to test. + * @param {*} field THe field name to test. + * @returns One of the Bootstrao validationState values. + */ +function getErrorState (props, field) { + const { errors, touched } = props + + // one of the Bootstrap validationState values. + return errors[field] && touched[field] + ? 'error' + : null +} + +const INVALID_PHONE_MSG = 'Please enter a valid phone number.' +const VALIDATION_CODE_MSG = 'Please enter 6 digits for the validation code.' +const codeValidationSchema = yup.object({ + validationCode: yup.string() + .required(VALIDATION_CODE_MSG) + .matches(/^\d{6}$/, VALIDATION_CODE_MSG) // 6-digit string +}) /** * User notification preferences pane. */ const NotificationPrefsPane = ({ - // All props below are Formik props (https://formik.org/docs/api/formik#props-1) - errors, - handleBlur, - handleChange, - touched, - values: userData + handleBlur, // Formik prop + handleChange, // Formik prop + loggedInUser, + onRequestPhoneVerificationCode, + onSendPhoneVerificationCode, + phoneFormatOptions, + values: userData // Formik prop }) => { const { email, - notificationChannel, + isPhoneNumberVerified, phoneNumber + } = loggedInUser + + const { + notificationChannel } = userData - let phoneValidationState = null - if (touched.phoneNumber && phoneNumber.length > 0) { - phoneValidationState = errors.phoneNumber ? 'error' : 'success' + const initialFormikValues = { + validationCode: '' } return ( @@ -67,6 +126,8 @@ const NotificationPrefsPane = ({ defaultValue={notificationChannel} > {allowedNotificationChannels.map(({ type, text }, index) => ( + // TODO: If removing the Save/Cancel buttons on the account screen, + // persist changes immediately when onChange is triggered. {notificationChannel === 'email' && ( - Notification emails will be sent out to: - + Notification emails will be sent to: + {email} )} {notificationChannel === 'sms' && ( - // FIXME: Merge the validation feedback upon approving PR #224. - - Enter your phone number for SMS notifications: - - - {errors.phoneNumber && {errors.phoneNumber}} - + + { + // Pass Formik props to the component rendered so Formik can manage its validation. + // (The validation for this component is independent of the validation set in UserAccountScreen.) + innerProps => { + return ( + + ) + } + } + )} @@ -110,3 +180,226 @@ const NotificationPrefsPane = ({ } export default NotificationPrefsPane + +/** + * Sub-component that handles phone number and validation code editing and validation intricacies. + */ +class PhoneNumberEditor extends Component { + constructor (props) { + super(props) + + const { initialPhoneNumber } = props + this.state = { + // If true, phone number is being edited. + // For new users, render component in editing state. + isEditing: isBlank(initialPhoneNumber), + + // Holds the new phone number (+15555550123 format) entered by the user + // (outside of Formik because (Phone)Input does not have a standard onChange event or simple valitity test). + newPhoneNumber: '' + } + } + + _handleEditNumber = () => { + this.setState({ + isEditing: true, + newPhoneNumber: '' + }) + } + + _handleNewPhoneNumberChange = newPhoneNumber => { + this.setState({ + newPhoneNumber + }) + } + + _handleCancelEditNumber = () => { + this.setState({ + isEditing: false + }) + } + + _handlePhoneNumberKeyDown = e => { + if (e.keyCode === 13) { + // On the user pressing enter (keyCode 13) on the phone number field, + // prevent form submission and request the code. + e.preventDefault() + this._handleRequestCode() + } + } + + _handleValidationCodeKeyDown = e => { + if (e.keyCode === 13) { + // On the user pressing enter (keyCode 13) on the validation code field, + // prevent form submission and send the validation code. + e.preventDefault() + this._handleSubmitCode() + } + } + + /** + * Send phone verification request with the entered values. + */ + _handleRequestCode = () => { + const { initialPhoneNumber, initialPhoneNumberVerified, onRequestCode } = this.props + const { newPhoneNumber } = this.state + + this._handleCancelEditNumber() + + // Send the SMS request if one of these conditions apply: + // - the user entered a valid phone number different than their current verified number, + // - the user clicks 'Request new code' for an already pending number + // (they could have refreshed the page in between). + let submittedNumber + + if (newPhoneNumber && + isPossiblePhoneNumber(newPhoneNumber) && + !(newPhoneNumber === initialPhoneNumber && initialPhoneNumberVerified)) { + submittedNumber = newPhoneNumber + } else if (this._isPhoneNumberPending()) { + submittedNumber = initialPhoneNumber + } + + if (submittedNumber) { + onRequestCode(submittedNumber) + } + } + + _handleSubmitCode = () => { + const { errors, onSubmitCode, values } = this.props + const { validationCode } = values + + if (!errors.validationCode) { + onSubmitCode(validationCode) + } + } + + _isPhoneNumberPending = () => { + const { initialPhoneNumber, initialPhoneNumberVerified } = this.props + return !isBlank(initialPhoneNumber) && !initialPhoneNumberVerified + } + + componentDidUpdate (prevProps) { + // If new phone number and verified status are received, + // then reset/clear the inputs. + if (this.props.phoneNumber !== prevProps.phoneNumber || + this.props.isPhoneNumberVerified !== prevProps.isPhoneNumberVerified + ) { + this._handleCancelEditNumber() + this.props.resetForm() + } + } + + render () { + const { + errors, // Formik prop + initialPhoneNumber, + phoneFormatOptions, + touched // Formik prop + } = this.props + const { isEditing, newPhoneNumber } = this.state + const isPending = this._isPhoneNumberPending() + + // Here are the states we are dealing with: + // - First time entering a phone number/validation code (blank value, not modified) + // => no color, no feedback indication. + // - Typing backspace all the way to erase a number/code (blank value, modified) + // => red error. + // - Typing a phone number that doesn't match the configured phoneNumberRegEx + // => red error. + const isPhoneInvalid = !isPossiblePhoneNumber(newPhoneNumber) + const showPhoneError = isPhoneInvalid && !isBlank(newPhoneNumber) + const phoneErrorState = showPhoneError ? 'error' : null + const codeErrorState = getErrorState(this.props, 'validationCode') + + return ( + <> + {isEditing + ? ( + // FIXME: If removing the Save/Cancel buttons on the account screen, + // make this a and remove onKeyDown handler. + + Enter your phone number for SMS notifications: + + + + Send verification text + + { // Show cancel button only if a phone number is already recorded. + initialPhoneNumber && Cancel} + {showPhoneError && {INVALID_PHONE_MSG}} + + + ) : ( + + SMS notifications will be sent to: + + + {formatPhoneNumber(initialPhoneNumber)} + {' '} + {isPending + // eslint-disable-next-line jsx-a11y/label-has-for + ? Pending + // eslint-disable-next-line jsx-a11y/label-has-for + : Verified + } + + Change number + + + )} + + {isPending && !isEditing && ( + // FIXME: If removing the Save/Cancel buttons on the account screen, + // make this a and remove onKeyDown handler. + + + Please check the SMS messaging app on your mobile phone + for a text message with a verification code, and enter the code below + (code expires after 10 minutes). + + Verification code: + + triggers the numerical keypad on mobile devices, and otherwise + // behaves like with support of leading zeros and the maxLength prop. + // causes values to be stored as Number, resulting in + // leading zeros to be invalid and stripped upon submission. + type='tel' + + // onBlur, onChange, and value are passed automatically by Formik + /> + + Verify + + {touched.validationCode && errors.validationCode && ( + {errors.validationCode} + )} + + Request a new code + + )} + > + ) + } +} diff --git a/lib/components/user/phone-verification-pane.js b/lib/components/user/phone-verification-pane.js deleted file mode 100644 index d1f9dbac5..000000000 --- a/lib/components/user/phone-verification-pane.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import { Alert, FormControl, FormGroup } from 'react-bootstrap' - -/** - * User phone verification pane. - * TODO: to be completed. - */ -const PhoneVerificationPane = () => ( - - - Under construction! - - - Please check your mobile phone's SMS messaging app for a text - message with a verification code and copy the code below: - - - - - -) - -export default PhoneVerificationPane diff --git a/lib/components/user/sequential-pane-display.js b/lib/components/user/sequential-pane-display.js index c5a41a782..0bcf3e564 100644 --- a/lib/components/user/sequential-pane-display.js +++ b/lib/components/user/sequential-pane-display.js @@ -27,18 +27,25 @@ class SequentialPaneDisplay extends Component { } } - _handleToNextPane = e => { + _handleToNextPane = async e => { const { paneSequence } = this.props const { activePaneId } = this.state - const nextId = paneSequence[activePaneId].nextId + const currentPane = paneSequence[activePaneId] + const nextId = currentPane.nextId if (nextId) { + // Don't submit the form if there are more steps to complete. + e.preventDefault() + + // Execute pane-specific action, if any (e.g. save a user account) + // when clicking next. + if (typeof currentPane.onNext === 'function') { + await currentPane.onNext() + } + this.setState({ activePaneId: nextId }) - - // Don't submit the form if there are more steps to complete. - e.preventDefault() } } diff --git a/lib/components/user/user-account-screen.js b/lib/components/user/user-account-screen.js index 7caf5f693..d11bcc001 100644 --- a/lib/components/user/user-account-screen.js +++ b/lib/components/user/user-account-screen.js @@ -5,8 +5,8 @@ import { connect } from 'react-redux' import { withLoginRequired } from 'use-auth0-hooks' import * as yup from 'yup' -import { routeTo } from '../../actions/ui' -import { createOrUpdateUser } from '../../actions/user' +import * as uiActions from '../../actions/ui' +import * as userActions from '../../actions/user' import { isNewUser } from '../../util/user' import DesktopNav from '../app/desktop-nav' import AccountSetupFinishPane from './account-setup-finish-pane' @@ -14,22 +14,15 @@ import ExistingAccountDisplay from './existing-account-display' import FavoriteLocationsPane, { isHome, isWork } from './favorite-locations-pane' import NewAccountWizard from './new-account-wizard' import NotificationPrefsPane from './notification-prefs-pane' -import PhoneVerificationPane from './phone-verification-pane' import TermsOfUsePane from './terms-of-use-pane' import VerifyEmailScreen from './verify-email-screen' import withLoggedInUserSupport from './with-logged-in-user-support' -// Regex for phone numbers from https://stackoverflow.com/questions/52483260/validate-phone-number-with-yup/53210158#53210158 -// https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch04s02.html -// FIXME: On merging with PR #224, remember to strip the non-numbers out and add +1 if there are only 10 digits. -const phoneRegExp = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/ // /^((\\+[1-9]{1,4}[ \\-]*)|(\\([0-9]{2,3}\\)[ \\-]*)|([0-9]{2,4})[ \\-]*)*?[0-9]{3,4}?[ \\-]*[0-9]{3,4}?$/ - // The validation schema for the form fields. const validationSchema = yup.object({ email: yup.string().email(), hasConsentedToTerms: yup.boolean().oneOf([true], 'You must agree to the terms to continue.'), notificationChannel: yup.string().oneOf(['email', 'sms', 'none']), - phoneNumber: yup.string().matches(phoneRegExp, 'Phone number is not valid'), savedLocations: yup.array().of(yup.object({ address: yup.string(), icon: yup.string(), @@ -39,13 +32,14 @@ const validationSchema = yup.object({ }) /** - * Makes a copy of the logged-in user data for the Formik initial state, - * with the 'home' and 'work' locations at the top of the savedLocations list - * so they are always shown and shown at the top of the FavoriteLocationsPane. - * Note: In the returned value, savedLocations is always a valid array. + * Makes a copy of the logged-in user data for the Formik initial state, with: + * - the 'home' and 'work' locations at the top of the savedLocations list + * so they are always shown and shown at the top of the FavoriteLocationsPane. + * Note: In the returned value, savedLocations is always a valid array. + * - initial values for phone number/code fields used by Formik. */ -function cloneWithHomeAndWorkAsTopLocations (loggedInUser) { - const clonedUser = clone(loggedInUser) +function cloneForFormik (userData) { + const clonedUser = clone(userData) const { savedLocations = [] } = clonedUser const homeLocation = savedLocations.find(isHome) || { @@ -72,21 +66,49 @@ function cloneWithHomeAndWorkAsTopLocations (loggedInUser) { * This screen handles creating/updating OTP user account settings. */ class UserAccountScreen extends Component { - _updateUserPrefs = async userData => { + constructor (props) { + super(props) + + this.state = { + // Capture whether user is a new user at this stage, and retain that value as long as this screen is active. + // Reminder: When a new user progresses through the account steps, + // isNewUser(loggedInUser) will change to false as the database gets updated. + isNewUser: isNewUser(props.loggedInUser), + + // Last number and last time we requested a code for (to avoid repeat SMS and not waste SMS quota). + lastPhoneNumberRequested: null, + lastPhoneRequestTime: null + } + } + + _updateUserPrefs = async (userData, silentOnSucceed = false) => { // TODO: Change state of Save button while the update action takes place. // In userData.savedLocations, filter out entries with blank addresses. const newUserData = clone(userData) newUserData.savedLocations = newUserData.savedLocations.filter(({ address }) => address && address.length) - await this.props.createOrUpdateUser(newUserData) + await this.props.createOrUpdateUser(newUserData, silentOnSucceed) // TODO: Handle UI feedback (currently an alert() dialog inside createOrUpdateUser). } + /** + * Silently persists the user data upon accepting terms. + * Creating the user record before the user finishes the account creation steps + * is required by the middleware in order to perform phone verification. + * + * @param {*} userData The user data state to persist. + * @returns The new user id the the caller can use. + */ + _handleCreateNewUser = userData => { + this._updateUserPrefs(userData, true) + } + _handleExit = () => { // On exit, route to default search route. this.props.routeTo('/') } + /** * Save changes and return to the planner. * @param {*} userData The user edited state to be saved, provided by Formik. @@ -100,7 +122,6 @@ class UserAccountScreen extends Component { _panes = { terms: TermsOfUsePane, notifications: NotificationPrefsPane, - verifyPhone: PhoneVerificationPane, locations: FavoriteLocationsPane, finish: AccountSetupFinishPane } @@ -108,20 +129,21 @@ class UserAccountScreen extends Component { // TODO: Update title bar during componentDidMount. render () { - const { auth, loggedInUser } = this.props - const handleExit = this._handleExit + const { auth, loggedInUser, phoneFormatOptions, requestPhoneVerificationSms, verifyPhoneNumber } = this.props return ( {/* TODO: Do mobile view. */} { // Formik props provide access to the current user data state and errors, @@ -129,30 +151,33 @@ class UserAccountScreen extends Component { // and to its own blur/change/submit event handlers that automate the state. // We pass the Formik props below to the components rendered so that individual controls // can be wired to be managed by Formik. - props => { + formikProps => { let formContents - if (isNewUser(loggedInUser)) { + let DisplayComponent + if (this.state.isNewUser) { if (!auth.user.email_verified) { // Check and prompt for email verification first to avoid extra user wait. formContents = } else { // New users are shown "wizard" (step-by-step) mode // (includes when a "new" user clicks "My Account" from the account menu in the nav bar). - formContents = ( - - ) + DisplayComponent = NewAccountWizard } } else { // Existing users are shown all panes together. + DisplayComponent = ExistingAccountDisplay + } + if (DisplayComponent) { formContents = ( - ) } @@ -174,13 +199,16 @@ class UserAccountScreen extends Component { const mapStateToProps = (state, ownProps) => { return { - loggedInUser: state.user.loggedInUser + loggedInUser: state.user.loggedInUser, + phoneFormatOptions: state.otp.config.phoneFormatOptions } } const mapDispatchToProps = { - createOrUpdateUser, - routeTo + createOrUpdateUser: userActions.createOrUpdateUser, + requestPhoneVerificationSms: userActions.requestPhoneVerificationSms, + routeTo: uiActions.routeTo, + verifyPhoneNumber: userActions.verifyPhoneNumber } export default withLoggedInUserSupport( diff --git a/lib/reducers/create-otp-reducer.js b/lib/reducers/create-otp-reducer.js index 22c5d2a3e..71a303a42 100644 --- a/lib/reducers/create-otp-reducer.js +++ b/lib/reducers/create-otp-reducer.js @@ -92,6 +92,14 @@ export function getInitialState (userDefinedConfig, initialQuery) { ) } + // Phone format options fall back to US region if not provided. + if (!config.phoneFormatOptions) { + config.phoneFormatOptions = {} + } + if (!config.phoneFormatOptions.countryCode) { + config.phoneFormatOptions.countryCode = 'US' + } + // Load user settings from local storage. // TODO: Make this work with settings fetched from alternative storage system // (e.g., OTP backend middleware containing user profile system). diff --git a/lib/reducers/create-user-reducer.js b/lib/reducers/create-user-reducer.js index 2dc9afbb6..952adc120 100644 --- a/lib/reducers/create-user-reducer.js +++ b/lib/reducers/create-user-reducer.js @@ -2,7 +2,13 @@ import update from 'immutability-helper' // TODO: port user-specific code from the otp reducer. function createUserReducer () { - const initialState = {} + const initialState = { + lastPhoneSmsRequest: { + number: null, + status: null, + timestamp: new Date(0) + } + } return (state = initialState, action) => { switch (action.type) { @@ -25,6 +31,12 @@ function createUserReducer () { }) } + case 'SET_LAST_PHONE_SMS_REQUEST': { + return update(state, { + lastPhoneSmsRequest: { $set: action.payload } + }) + } + default: return state } diff --git a/lib/util/middleware.js b/lib/util/middleware.js index 10403b465..5db426133 100644 --- a/lib/util/middleware.js +++ b/lib/util/middleware.js @@ -1,8 +1,5 @@ if (typeof (fetch) === 'undefined') require('isomorphic-fetch') -const API_USER_PATH = '/api/secure/user' -const API_MONITORTRIP_PATH = '/api/secure/monitoredtrip' - /** * This method builds the options object for call to the fetch method. * @param {string} accessToken If non-null, a bearer Authorization header will be added with the specified token. @@ -58,84 +55,3 @@ export async function secureFetch (url, accessToken, apiKey, method = 'get', opt data: await res.json() } } - -// TODO: Move methods below to user/entity-specific files? -export async function fetchUser (middlewareConfig, token) { - const { apiBaseUrl, apiKey } = middlewareConfig - const requestUrl = `${apiBaseUrl}${API_USER_PATH}/fromtoken` - - return secureFetch(requestUrl, token, apiKey) -} - -export async function addUser (middlewareConfig, token, data) { - const { apiBaseUrl, apiKey } = middlewareConfig - const requestUrl = `${apiBaseUrl}${API_USER_PATH}` - - return secureFetch(requestUrl, token, apiKey, 'POST', { - body: JSON.stringify(data) - }) -} - -export async function updateUser (middlewareConfig, token, data) { - const { apiBaseUrl, apiKey } = middlewareConfig - const { id } = data // Middleware ID, NOT auth0 (or similar) id. - const requestUrl = `${apiBaseUrl}${API_USER_PATH}/${id}` - - if (id) { - return secureFetch(requestUrl, token, apiKey, 'PUT', { - body: JSON.stringify(data) - }) - } else { - return { - status: 'error', - message: 'Corrupted state: User ID not available for exiting user.' - } - } -} - -export async function getTrips (middlewareConfig, token) { - const { apiBaseUrl, apiKey } = middlewareConfig - const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}` - - return secureFetch(requestUrl, token, apiKey, 'GET') -} - -export async function addTrip (middlewareConfig, token, data) { - const { apiBaseUrl, apiKey } = middlewareConfig - const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}` - - return secureFetch(requestUrl, token, apiKey, 'POST', { - body: JSON.stringify(data) - }) -} - -export async function updateTrip (middlewareConfig, token, data) { - const { apiBaseUrl, apiKey } = middlewareConfig - const { id } = data - const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}/${id}` - - if (id) { - return secureFetch(requestUrl, token, apiKey, 'PUT', { - body: JSON.stringify(data) - }) - } else { - return { - status: 'error', - message: 'Corrupted state: Monitored Trip ID not available for exiting user.' - } - } -} - -export async function deleteTrip (middlewareConfig, token, id) { - const { apiBaseUrl, apiKey } = middlewareConfig - const requestUrl = `${apiBaseUrl}${API_MONITORTRIP_PATH}/${id}` - - if (id) { - return secureFetch(requestUrl, token, apiKey, 'DELETE') - } else { - return { - status: 'error', - message: 'Corrupted state: Monitored Trip ID not available for exiting user.' - } - } -} diff --git a/lib/util/ui.js b/lib/util/ui.js index b9c4b3923..5231c2a80 100644 --- a/lib/util/ui.js +++ b/lib/util/ui.js @@ -1,5 +1,13 @@ import { Children, isValidElement, cloneElement } from 'react' +/** + * @param {*} string the string to test. + * @returns true if the string is null or of zero length. + */ +export function isBlank (string) { + return !(!!string && string.length) +} + /** * Renders children with additional props. * Modified from diff --git a/package.json b/package.json index 95872ad2c..208c071d9 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "react-draggable": "^4.4.3", "react-fontawesome": "^1.5.0", "react-loading-skeleton": "^2.1.1", + "react-phone-number-input": "^3.1.0", "react-redux": "^7.1.0", "react-resize-detector": "^2.1.0", "react-router": "^5.0.1", diff --git a/yarn.lock b/yarn.lock index 9257334c6..a5ffb79e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5011,6 +5011,11 @@ cosmiconfig@^6.0.0: path-type "^4.0.0" yaml "^1.7.2" +country-flag-icons@^1.0.2: + version "1.2.5" + resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.2.5.tgz#784185503f3589e650b30402c93ef7cc2a3225a9" + integrity sha512-5V7GEpGLG+uyLUf0qs35Ub80/Nnjtymfax7wwv7DMJFeA9PZWVYIck7OAuBP2FSL8Xxaqm0qMNmpufVyHGEHlw== + create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -8060,6 +8065,13 @@ inline-source-map@~0.6.0: dependencies: source-map "~0.5.3" +input-format@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/input-format/-/input-format-0.3.0.tgz#f662b1667b6d067769f44c717bcfcc92554bee75" + integrity sha512-7ipaXJ5Hnd2o62IxLRTCMcl7AOPzxU2PTfDunPDIaaAjq+Q8DcjI4/nmPo3fXJUvdTCX97BlW8d+7ArUhVTxAA== + dependencies: + prop-types "^15.7.2" + inquirer@6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8" @@ -9565,6 +9577,14 @@ libnpx@^10.2.2: y18n "^4.0.0" yargs "^11.0.0" +libphonenumber-js@^1.8.1: + version "1.8.4" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.8.4.tgz#e84eaa25b96bfebb2ac1a5c847b7f7f17da5bd59" + integrity sha512-s0fTPZRB4hcsfDL9p6wUNOLngVh4y3fBPhH33dL7OfkHA2RirI8p3rlR+4f4SMxdcng9jPNOweS2Z47BivHMYw== + dependencies: + minimist "^1.2.5" + xml2js "^0.4.17" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -13426,6 +13446,17 @@ react-overlays@^0.8.0: react-transition-group "^2.2.0" warning "^3.0.0" +react-phone-number-input@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/react-phone-number-input/-/react-phone-number-input-3.1.0.tgz#55e153945d8a3c3294c3469e3c58dfdfc2984961" + integrity sha512-xJQXmpRtpVmwOM59JNUj8iOOBCRTM6VvV+5PAek+H/R6D+TTKO2QSelu9WMK+C18h17RznHe77QXlBx1Q3S6NA== + dependencies: + classnames "^2.2.5" + country-flag-icons "^1.0.2" + input-format "^0.3.0" + libphonenumber-js "^1.8.1" + prop-types "^15.7.2" + react-portal@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.2.0.tgz#4224e19b2b05d5cbe730a7ba0e34ec7585de0043" @@ -16919,6 +16950,19 @@ xml2js@0.4.19: sax ">=0.6.0" xmlbuilder "~9.0.1" +xml2js@^0.4.17: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + xmlbuilder@~9.0.1: version "9.0.7" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
+ Please check the SMS messaging app on your mobile phone + for a text message with a verification code, and enter the code below + (code expires after 10 minutes). +
- Please check your mobile phone's SMS messaging app for a text - message with a verification code and copy the code below: -