diff --git a/libs/accounts/recovery-phone/src/index.ts b/libs/accounts/recovery-phone/src/index.ts index 3cc0239dfa6..657cd8fb67d 100644 --- a/libs/accounts/recovery-phone/src/index.ts +++ b/libs/accounts/recovery-phone/src/index.ts @@ -3,7 +3,7 @@ export * from './lib/recovery-phone.service'; export * from './lib/recovery-phone.provider'; export * from './lib/recovery-phone.service.config'; export * from './lib/sms.manager'; -export * from './lib/sms.manger.config'; +export * from './lib/sms.manager.config'; export * from './lib/twilio.config'; export * from './lib/twilio.provider'; export * from './lib/recovery-phone.errors'; diff --git a/libs/accounts/recovery-phone/src/lib/recovery-phone.provider.ts b/libs/accounts/recovery-phone/src/lib/recovery-phone.provider.ts index 873c4f436dc..65f8bb6c959 100644 --- a/libs/accounts/recovery-phone/src/lib/recovery-phone.provider.ts +++ b/libs/accounts/recovery-phone/src/lib/recovery-phone.provider.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { ConfigService } from '@nestjs/config'; -import { SmsManagerConfig } from './sms.manger.config'; +import { SmsManagerConfig } from './sms.manager.config'; import Redis from 'ioredis'; import { RecoveryPhoneConfig } from './recovery-phone.service.config'; diff --git a/libs/accounts/recovery-phone/src/lib/sms.manger.config.ts b/libs/accounts/recovery-phone/src/lib/sms.manager.config.ts similarity index 100% rename from libs/accounts/recovery-phone/src/lib/sms.manger.config.ts rename to libs/accounts/recovery-phone/src/lib/sms.manager.config.ts diff --git a/libs/accounts/recovery-phone/src/lib/sms.manager.spec.ts b/libs/accounts/recovery-phone/src/lib/sms.manager.spec.ts index 278076342b5..ad6017cc5a1 100644 --- a/libs/accounts/recovery-phone/src/lib/sms.manager.spec.ts +++ b/libs/accounts/recovery-phone/src/lib/sms.manager.spec.ts @@ -6,7 +6,7 @@ import { LOGGER_PROVIDER } from '@fxa/shared/log'; import { StatsDService } from '@fxa/shared/metrics/statsd'; import { Test, TestingModule } from '@nestjs/testing'; import { SmsManager } from './sms.manager'; -import { SmsManagerConfig } from './sms.manger.config'; +import { SmsManagerConfig } from './sms.manager.config'; import { TwilioProvider } from './twilio.provider'; import { TwilioErrorCodes } from './recovery-phone.errors'; diff --git a/libs/accounts/recovery-phone/src/lib/sms.manager.ts b/libs/accounts/recovery-phone/src/lib/sms.manager.ts index bfdee33aab7..39914d614bb 100644 --- a/libs/accounts/recovery-phone/src/lib/sms.manager.ts +++ b/libs/accounts/recovery-phone/src/lib/sms.manager.ts @@ -8,7 +8,7 @@ import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { StatsD } from 'hot-shots'; import { Twilio } from 'twilio'; import { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message'; -import { SmsManagerConfig } from './sms.manger.config'; +import { SmsManagerConfig } from './sms.manager.config'; import { TwilioProvider } from './twilio.provider'; import { RecoveryNumberInvalidFormatError, @@ -70,6 +70,7 @@ export class SmsManager { retryCount: number ): Promise { const from = this.rotateFromNumber(); + try { const msg = await this.client.messages.create({ to, diff --git a/packages/fxa-auth-server/lib/authMethods.js b/packages/fxa-auth-server/lib/authMethods.js index ab0d6a17031..1f7c1bb2d8b 100644 --- a/packages/fxa-auth-server/lib/authMethods.js +++ b/packages/fxa-auth-server/lib/authMethods.js @@ -19,6 +19,7 @@ const METHOD_TO_AMR = { 'email-2fa': 'email', 'totp-2fa': 'otp', 'recovery-code': 'otp', + 'sms-2fa': 'otp', }; // Maps AMR values to the type of authenticator they represent, e.g. diff --git a/packages/fxa-auth-server/test/remote/recovery_phone_tests.js b/packages/fxa-auth-server/test/remote/recovery_phone_tests.js index 977437d9888..682bfedfbfe 100644 --- a/packages/fxa-auth-server/test/remote/recovery_phone_tests.js +++ b/packages/fxa-auth-server/test/remote/recovery_phone_tests.js @@ -101,7 +101,7 @@ describe(`#integration - recovery phone`, function () { await TestServer.stop(server); }); - it('setups a recovery phone', async function () { + it('sets up a recovery phone', async function () { if (!isTwilioConfigured) { this.skip('Invalid twilio accountSid or authToken. Check env / config!'); } @@ -183,7 +183,7 @@ describe(`#integration - recovery phone`, function () { assert.isFalse(checkResp2.exists); }); - it('fails to setup invalid phone number', async function () { + it('fails to set up invalid phone number', async function () { if (!isTwilioConfigured) { this.skip('Invalid twilio accountSid or authToken. Check env / config!'); } diff --git a/packages/fxa-content-server/app/scripts/lib/router.js b/packages/fxa-content-server/app/scripts/lib/router.js index d7f2699e25b..f07da106759 100644 --- a/packages/fxa-content-server/app/scripts/lib/router.js +++ b/packages/fxa-content-server/app/scripts/lib/router.js @@ -527,12 +527,18 @@ Router = Router.extend({ 'signin_permissions(/)': createViewHandler(PermissionsView, { type: VerificationReasons.SIGN_IN, }), + 'signin_recovery_choice(/)': function () { + this.createReactViewHandler('signin_recovery_choice'); + }, 'signin_recovery_code(/)': function () { this.createReactOrBackboneViewHandler( 'signin_recovery_code', SignInRecoveryCodeView ); }, + 'sigin_recovery_phone(/)': function () { + this.createReactViewHandler('signin_recovery_phone'); + }, 'signin_reported(/)': function () { this.createReactOrBackboneViewHandler( 'signin_reported', diff --git a/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js b/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js index 273dafd3a88..eb217153f9e 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/content-server-routes.js @@ -67,6 +67,8 @@ const FRONTEND_ROUTES = [ 'signin_push_code_confirm', 'signin_totp_code', 'signin_recovery_code', + 'signin_recovery_choice', + 'signin_recovery_phone', 'signin_confirmed', 'signin_permissions', 'signin_reported', diff --git a/packages/fxa-content-server/server/lib/routes/react-app/index.js b/packages/fxa-content-server/server/lib/routes/react-app/index.js index 94304670f2a..d7d941124c3 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/index.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/index.js @@ -82,6 +82,8 @@ const getReactRouteGroups = (showReactApp, reactRoute) => { 'signin_unblock', 'force_auth', 'signin_recovery_code', + 'signin_recovery_choice', + 'signin_recovery_phone', 'inline_totp_setup', 'inline_recovery_setup', 'inline_recovery_key_setup', diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 188a360994e..9a1e6144580 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -2,7 +2,12 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { RouteComponentProps, Router, useLocation } from '@reach/router'; +import { + Redirect, + RouteComponentProps, + Router, + useLocation, +} from '@reach/router'; import { lazy, Suspense, @@ -80,6 +85,8 @@ import WebChannelExample from '../../pages/WebChannelExample'; import SignoutSync from '../Settings/SignoutSync'; import InlineRecoveryKeySetupContainer from '../../pages/InlineRecoveryKeySetup/container'; import SetPasswordContainer from '../../pages/PostVerify/SetPassword/container'; +import SigninRecoveryChoiceContainer from '../../pages/Signin/SigninRecoveryChoice/container'; +import SigninRecoveryPhoneContainer from '../../pages/Signin/SigninRecoveryPhone/container'; const Settings = lazy(() => import('../Settings')); @@ -290,6 +297,7 @@ const AuthAndAccountSetupRoutes = ({ integration: Integration; flowQueryParams: QueryParams; } & RouteComponentProps) => { + const config = useConfig(); const localAccount = currentAccount(); // TODO: MozServices / string discrepancy, FXA-6802 const serviceName = integration.getServiceName() as MozServices; @@ -378,9 +386,31 @@ const AuthAndAccountSetupRoutes = ({ path="/signin_confirmed/*" {...{ isSignedIn, serviceName }} /> + {config.featureFlags?.enableUsing2FABackupPhone ? ( + <> + + + + ) : ( + <> + + + + )} { @@ -123,7 +123,7 @@ function applyDefaultMocks() { jest.resetAllMocks(); jest.restoreAllMocks(); mockModelsModule(); - mockInlineRecoveryKeySetupModule(); + mockSetPasswordModule(); mockCurrentAccount(MOCK_STORED_ACCOUNT); (useFinishOAuthFlowHandler as jest.Mock).mockImplementation(() => ({ finishOAuthFlowHandler: jest diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.test.tsx new file mode 100644 index 00000000000..0db930e642e --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.test.tsx @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as ModelsModule from '../../../models'; +import * as ReachRouterModule from '@reach/router'; +import * as ReactUtils from 'fxa-react/lib/utils'; +import * as CacheModule from '../../../lib/cache'; +import * as SigninRecoveryChoiceModule from './index'; + +import { LocationProvider } from '@reach/router'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { MOCK_STORED_ACCOUNT, mockLoadingSpinnerModule } from '../../mocks'; +import { mockSigninLocationState } from '../mocks'; +import { SigninRecoveryChoiceProps } from '.'; +import SigninRecoveryChoiceContainer from './container'; +import AuthClient from 'fxa-auth-client/lib/client'; + +// mock custom glean events if needed + +jest.mock('../../../models', () => { + return { + ...jest.requireActual('../../../models'), + useAuthClient: jest.fn(), + }; +}); + +const mockAuthClient = new AuthClient('http://localhost:9000', { + keyStretchVersion: 1, +}); + +function mockModelsModule() { + mockAuthClient.recoveryKeyExists = jest.fn().mockResolvedValue({ + hasBackupCodes: true, + count: 3, + }); + mockAuthClient.recoveryPhoneGet = jest + .fn() + .mockResolvedValue({ exists: true, number: '7890' }); + (ModelsModule.useAuthClient as jest.Mock).mockImplementation( + () => mockAuthClient + ); +} + +let currentSigninRecoveryChoiceProps: SigninRecoveryChoiceProps | undefined; +function mockSigninRecoveryChoiceModule() { + jest + .spyOn(SigninRecoveryChoiceModule, 'default') + .mockImplementation((props: SigninRecoveryChoiceProps) => { + currentSigninRecoveryChoiceProps = props; + return
signin recovery choice mock
; + }); +} + +function mockCache(opts: any = {}, isEmpty = false) { + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue( + isEmpty + ? undefined + : { + sessionToken: '123', + ...(opts || {}), + } + ); +} + +function mockReactUtilsModule() { + jest.spyOn(ReactUtils, 'hardNavigate').mockImplementation(() => {}); +} + +const mockLocation = (pathname: string, mockLocationState: Object) => { + return { + ...global.window.location, + pathname, + state: mockLocationState, + }; +}; + +function mockReachRouter( + mockNavigate = jest.fn(), + pathname = '', + mockLocationState = {} +) { + mockNavigate.mockReset(); + jest.spyOn(ReachRouterModule, 'useNavigate').mockReturnValue(mockNavigate); + jest + .spyOn(ReachRouterModule, 'useLocation') + .mockImplementation(() => mockLocation(pathname, mockLocationState)); +} + +function applyDefaultMocks() { + jest.resetAllMocks(); + jest.restoreAllMocks(); + mockModelsModule(); + mockSigninRecoveryChoiceModule(); + mockLoadingSpinnerModule(); + mockReactUtilsModule(); + mockCache(); + mockReachRouter(undefined, 'signin_recovery_choice', mockSigninLocationState); +} + +function render() { + renderWithLocalizationProvider( + + + + ); +} + +describe('SigninRecoveryChoice container', () => { + beforeEach(() => { + applyDefaultMocks(); + }); + + describe('initial state', () => { + it('redirects if page is reached without location state', async () => { + mockReachRouter(undefined, 'signin_recovery_choice'); + mockCache({}, true); + await render(); + // expect navigation to signin + }); + + it('redirects if there is no sessionToken', async () => { + mockReachRouter(undefined, 'signin_recovery_choice'); + mockCache({ sessionToken: '' }); + await render(); + // expect navigation to signin + }); + + it('retrieves the session token from local storage if no location state', async () => { + mockReachRouter(undefined, 'signin_recovery_choice', {}); + mockCache(MOCK_STORED_ACCOUNT); + await render(); + // expect navigation to signin + }); + }); + + describe('fetches recovery method data', () => { + it('successful', async () => { + // fill in test + }); + + it('handles errors', async () => { + // fill in test + }); + }); + + // redirects if no recovery phone +}); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.tsx new file mode 100644 index 00000000000..2cbda7f2d46 --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/container.tsx @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useState, useEffect } from 'react'; +import { RouteComponentProps, useLocation } from '@reach/router'; +import SigninRecoveryChoice from '.'; +import { useAuthClient, useFtlMsgResolver } from '../../../models'; +import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; +import { SigninLocationState } from '../interfaces'; +import { getSigninState } from '../utils'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; +import { + getHandledError, + getLocalizedErrorMessage, +} from '../../../lib/error-utils'; +import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; + +export const SigninRecoveryChoiceContainer = (_: RouteComponentProps) => { + const authClient = useAuthClient(); + const ftlMsgResolver = useFtlMsgResolver(); + const location = useLocation() as ReturnType & { + state: SigninLocationState; + }; + const navigateWithQuery = useNavigateWithQuery(); + const signinState = getSigninState(location.state); + + const [numBackupCodes, setNumBackupCodes] = useState(0); + const [lastFourPhoneDigits, setLastFourPhoneDigits] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!signinState || !signinState.sessionToken) { + navigateWithQuery('/signin'); + return; + } + + const fetchData = async () => { + try { + const { count } = await authClient.getRecoveryCodesExist( + signinState.sessionToken + ); + count && setNumBackupCodes(count); + + const { phoneNumber } = await authClient.recoveryPhoneGet( + signinState.sessionToken + ); + // TODO verify that recoveryPhoneGet returns a masked phone number (last four digits only) + phoneNumber && setLastFourPhoneDigits(phoneNumber.slice(-4)); + + if (!phoneNumber) { + navigateWithQuery('/signin_recovery_code', { + state: { signinState }, + // ensure back button on signin_recovery_code page skips choice page and returns to signin_totp_code + replace: true, + }); + return; + } + + // handle the case where the user has no recovery methods + // show the backup authentication code page anyway, or redirect to SUMO? + } catch (error) { + const handledError = getHandledError(error); + if (handledError.error.errno === AuthUiErrors.INVALID_TOKEN.errno) { + navigateWithQuery('/signin'); + return; + } + // if there was another error fetching available recovery methods, go to backup authentication codes page + navigateWithQuery('/signin_recovery_code', { + state: { signinState }, + // ensure back button on signin_recovery_code page skips choice page and returns to signin_totp_code + replace: true, + }); + return; + } finally { + setLoading(false); + } + }; + fetchData(); + }, [authClient, signinState, navigateWithQuery]); + + const handlePhoneChoice = async () => { + if (!signinState) { + return; + } + try { + await authClient.recoveryPhoneSendCode(signinState.sessionToken); + navigateWithQuery('/signin_recovery_phone', { + state: { signinState, lastFourPhoneDigits }, + }); + return; + } catch (error) { + const handledError = getHandledError(error); + if (handledError.error.errno === AuthUiErrors.INVALID_TOKEN.errno) { + navigateWithQuery('/signin'); + return; + } + // if smsSendRateLimitExceeded or backendServiceFailure, prompt the user to try later or use backup authentication codes + if ( + handledError.error.errno === + AuthUiErrors.BACKEND_SERVICE_FAILURE.errno || + handledError.error.errno === + AuthUiErrors.SMS_SEND_RATE_LIMIT_EXCEEDED.errno + ) { + return ftlMsgResolver.getMsg( + 'signin-recovery-phone-send-code-failure', + 'There was a problem sending a code to your recovery phone. Please try again later or use your backup authentication codes.' + ); + } + return getLocalizedErrorMessage(ftlMsgResolver, handledError.error); + } + }; + + if (loading) { + return ; + } + + if (!signinState) { + return null; // Prevent rendering if `signinState` is invalid + } + + return ( + + ); +}; + +export default SigninRecoveryChoiceContainer; diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/en.ftl b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/en.ftl similarity index 64% rename from packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/en.ftl rename to packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/en.ftl index 494582181b3..9169083fdb9 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/en.ftl +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/en.ftl @@ -6,6 +6,8 @@ signin-recovery-method-header = Sign in signin-recovery-method-subheader = Choose a recovery method signin-recovery-method-details = Let’s make sure it’s you using your recovery methods. signin-recovery-method-phone = Recovery phone -signin-recovery-method-code = Authentication codes +signin-recovery-method-code-v2 = Backup authentication codes # Variable: $numberOfCodes (String) - The number of authentication codes the user has left, e.g. 4 signin-recovery-method-code-info = { $numberOfCodes } codes remaining +# Shown when a backend service fails and a code cannot be sent to the user's recovery phone. +signin-recovery-phone-send-code-failure = There was a problem sending a code to your recovery phone. Please try again later or use your backup authentication codes. diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.stories.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.stories.tsx similarity index 50% rename from packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.stories.tsx rename to packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.stories.tsx index b745c1d4afa..a5ca65e6b6f 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.stories.tsx @@ -4,13 +4,21 @@ import React from 'react'; import { Meta } from '@storybook/react'; -import SigninRecoveryMethod from '.'; +import SigninRecoveryChoice from '.'; import { withLocalization } from 'fxa-react/lib/storybooks'; +import { MOCK_SIGNIN_LOCATION_STATE } from './mocks'; export default { - title: 'Pages/Signin/SigninRecoveryMethod', - component: SigninRecoveryMethod, + title: 'Pages/Signin/SigninRecoveryChoice', + component: SigninRecoveryChoice, decorators: [withLocalization], } as Meta; -export const Default = () => ; +export const Default = () => ( + Promise.resolve('')} + lastFourPhoneDigits="1234" + numBackupCodes={4} + signinState={MOCK_SIGNIN_LOCATION_STATE} + /> +); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.test.tsx similarity index 64% rename from packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.test.tsx rename to packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.test.tsx index efdf238b043..cbb96bdc4bc 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.test.tsx @@ -3,16 +3,26 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from 'react'; -import FormChoice from '.'; +import { LocationProvider } from '@reach/router'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import { screen, within } from '@testing-library/react'; -import SigninRecoveryMethod from '.'; +import SigninRecoveryChoice from '.'; +import { MOCK_SIGNIN_LOCATION_STATE } from './mocks'; function render() { - renderWithLocalizationProvider(); + renderWithLocalizationProvider( + + + + ); } -describe('SigninRecoveryMethod', () => { +describe('SigninRecoveryChoice', () => { it('renders as expected', () => { render(); @@ -35,8 +45,9 @@ describe('SigninRecoveryMethod', () => { screen.getByText('Let’s make sure it’s you using your recovery methods.') ).toBeInTheDocument(); expect(screen.getByLabelText(/Recovery phone/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/4 codes remaining/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Authentication codes/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/••••••3019/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/••••••1234/i)).toBeInTheDocument(); + expect( + screen.getByLabelText(/Backup authentication codes/i) + ).toBeInTheDocument(); }); }); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.tsx similarity index 53% rename from packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.tsx rename to packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.tsx index d3ab52adc7a..7bed68a424f 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryMethod/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/index.tsx @@ -17,17 +17,43 @@ import { BackupRecoveryPhoneSmsImage, } from '../../../components/images'; import ButtonBack from '../../../components/ButtonBack'; +import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; +import Banner from '../../../components/Banner'; +import { SigninLocationState } from '../interfaces'; -const SigninRecoveryMethod = () => { +export type SigninRecoveryChoiceProps = { + handlePhoneChoice: () => Promise; + lastFourPhoneDigits: string; + numBackupCodes: number; + signinState: SigninLocationState; +}; + +const SigninRecoveryChoice = ({ + handlePhoneChoice, + lastFourPhoneDigits, + numBackupCodes, + signinState, +}: SigninRecoveryChoiceProps) => { + const [localizedErrorMessage, setLocalizedErrorMessage] = React.useState(''); + + const navigateWithQuery = useNavigateWithQuery(); const onSubmit = async ({ choice }: FormChoiceData) => { - // TODO: actually do something with this, maybe pull into container - console.log('Submitted with choice:', choice); + switch (choice) { + case CHOICES.phone: + const localizedError = await handlePhoneChoice(); + localizedError && setLocalizedErrorMessage(localizedError); + break; + case CHOICES.code: + // navigate to code page + // FXA-10374 fix navigation, currently it's not navigating successfully after entering code (redirecting to signin) + navigateWithQuery('/signin_recovery_code', { + state: { signinState, lastFourPhoneDigits }, + }); + break; + } }; const ftlMsgResolver = useFtlMsgResolver(); - // TODO, actually pull these values - const numberOfCodes = 4; - const lastFourPhoneNumber = 3019; const formChoices: FormChoiceOption[] = [ { @@ -39,32 +65,39 @@ const SigninRecoveryMethod = () => { 'Recovery phone' ), // This doesn't need localization - localizedChoiceInfo: `••••••${lastFourPhoneNumber}`, + localizedChoiceInfo: `••••••${lastFourPhoneDigits}`, }, { id: 'recovery-choice-code', value: CHOICES.code, image: , localizedChoiceTitle: ftlMsgResolver.getMsg( - 'signin-recovery-method-code', - 'Authentication codes' + 'signin-recovery-method-code-v2', + 'Backup authentication codes' ), localizedChoiceInfo: ftlMsgResolver.getMsg( 'signin-recovery-method-code-info', - `${numberOfCodes} codes remaining`, - { numberOfCodes } + `${numBackupCodes} codes remaining`, + { numberOfCodes: numBackupCodes } ), }, ]; return ( -
+
- Sign in + Sign in
+ {/* FXA-10374 - This banner should be below the form legend - verify how to accomplish that */} + {localizedErrorMessage && ( + + )} @@ -85,4 +118,4 @@ const legendEl = ( ); -export default SigninRecoveryMethod; +export default SigninRecoveryChoice; diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/mocks.ts b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/mocks.ts new file mode 100644 index 00000000000..014a5343079 --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryChoice/mocks.ts @@ -0,0 +1,8 @@ +import { MOCK_EMAIL, MOCK_SESSION_TOKEN, MOCK_UID } from '../../mocks'; + +export const MOCK_SIGNIN_LOCATION_STATE = { + email: MOCK_EMAIL, + sessionToken: MOCK_SESSION_TOKEN, + uid: MOCK_UID, + verified: false, +}; diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx index a33da5602c4..169c36276a0 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx @@ -13,7 +13,6 @@ import { LocationProvider } from '@reach/router'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import SigninRecoveryCodeContainer from './container'; import { createMockWebIntegration } from '../../../lib/integrations/mocks'; -import { MozServices } from '../../../lib/types'; import { Integration, useSensitiveDataClient } from '../../../models'; import { mockSensitiveDataClient as createMockSensitiveDataClient } from '../../../models/mocks'; import { @@ -134,7 +133,6 @@ function render(mocks: Array) { diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx index 3b4f7754d5d..5135e0ddd33 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx @@ -4,7 +4,6 @@ import { RouteComponentProps, useLocation } from '@reach/router'; import { hardNavigate } from 'fxa-react/lib/utils'; -import { MozServices } from '../../../lib/types'; import SigninRecoveryCode from '.'; import { Integration, @@ -26,29 +25,32 @@ import OAuthDataError from '../../../components/OAuthDataError'; import { getHandledError } from '../../../lib/error-utils'; import { SensitiveData } from '../../../lib/sensitive-data-client'; +type SigninRecoveryCodeLocationState = { + signinState: SigninLocationState; + lastFourPhoneDigits: string; +}; + export type SigninRecoveryCodeContainerProps = { integration: Integration; - serviceName: MozServices; }; export const SigninRecoveryCodeContainer = ({ integration, - serviceName, }: SigninRecoveryCodeContainerProps & RouteComponentProps) => { const authClient = useAuthClient(); const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler( authClient, integration ); - // TODO: FXA-9177, likely use Apollo cache here instead of location state - const location = useLocation() as ReturnType & { - state: SigninLocationState; - }; - const signinState = getSigninState(location.state); + const location = + (useLocation() as ReturnType & { + state: SigninRecoveryCodeLocationState; + }) || {}; + const signinState = getSigninState(location.state?.signinState); + const lastFourPhoneDigits = location.state?.lastFourPhoneDigits; const sensitiveDataClient = useSensitiveDataClient(); - const { keyFetchToken, unwrapBKey } = sensitiveDataClient.getDataType( - SensitiveData.Key.Auth - )!; + const { keyFetchToken, unwrapBKey } = + sensitiveDataClient.getDataType(SensitiveData.Key.Auth) || {}; const { oAuthKeysCheckError } = useOAuthKeysCheck( integration, @@ -64,8 +66,8 @@ export const SigninRecoveryCodeContainer = ({ async (recoveryCode: string) => { try { // this mutation returns the number of remaining codes, - // but we're not currently using that value client-side - // may want to see if we need it for /settings (display number of remaining backup codes?) + // if remaining codes is 0, we may want to redirect to the new code set up + // or show a message that the user has no more codes const { data } = await consumeRecoveryCode({ variables: { input: { code: recoveryCode } }, }); @@ -95,11 +97,11 @@ export const SigninRecoveryCodeContainer = ({ {...{ finishOAuthFlowHandler, integration, - serviceName, signinState, submitRecoveryCode, keyFetchToken, unwrapBKey, + lastFourPhoneDigits, }} /> ); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/en.ftl b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/en.ftl index 8895f053a5f..8da7e31ee8c 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/en.ftl +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/en.ftl @@ -9,8 +9,8 @@ signin-recovery-code-instruction-v2 = Enter one of the one-time use backup authe signin-recovery-code-input-label-v2 = Enter 10-character code # Form button to confirm if the backup authentication code entered by the user is valid signin-recovery-code-confirm-button = Confirm -# Link to return to signin with two-step authentication code -signin-recovery-code-back-link = Back +# Link to go to the page to use recovery phone instead +signin-recovery-code-phone-link = Use recovery phone # External link for support if the user can't use two-step autentication or a backup authentication code # https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication signin-recovery-code-support-link = Are you locked out? diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.stories.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.stories.tsx index 5d29e42da32..4cbb71ee1d2 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.stories.tsx @@ -5,7 +5,6 @@ import React from 'react'; import SigninRecoveryCode from '.'; import { Meta } from '@storybook/react'; -import { MozServices } from '../../../lib/types'; import { withLocalization } from 'fxa-react/lib/storybooks'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { LocationProvider } from '@reach/router'; @@ -57,16 +56,6 @@ export const WithOAuthDesktopServiceRelay = () => ( /> ); -export const WithServiceName = () => ( - -); - export const WithCodeErrorOnSubmit = () => ( { }); screen.getByRole('button', { name: 'Confirm' }); - screen.getByRole('link', { name: 'Back' }); + screen.getByRole('button', { name: 'Back' }); screen.getByRole('link', { name: /Are you locked out?/, }); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx index 555b8c3202b..672e6fc3d5a 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx @@ -23,17 +23,19 @@ import { useWebRedirect } from '../../../lib/hooks/useWebRedirect'; import { isBase32Crockford } from '../../../lib/utilities'; import Banner from '../../../components/Banner'; import { HeadingPrimary } from '../../../components/HeadingPrimary'; +import ButtonBack from '../../../components/ButtonBack'; +import classNames from 'classnames'; export const viewName = 'signin-recovery-code'; const SigninRecoveryCode = ({ finishOAuthFlowHandler, integration, - serviceName, signinState, submitRecoveryCode, keyFetchToken, unwrapBKey, + lastFourPhoneDigits, }: SigninRecoveryCodeProps & RouteComponentProps) => { useEffect(() => { GleanMetrics.loginBackupCode.view(); @@ -150,9 +152,13 @@ const SigninRecoveryCode = ({ return ( - - Sign in - +
+ {/* FXA-10374 fix, pass state */} + + + Sign in + +
{bannerErrorMessage && ( -
- - - Back - - +
+ {lastFourPhoneDigits && ( + + {/* FXA-10374 - only show this link if feature available AND configured + + add click handler - this link needs to navigate AND send an SMS code + handle errors */} + + Use recovery phone + + + )} Are you locked out? diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts index ed43199cff5..29c4e6a17e7 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts @@ -5,15 +5,14 @@ import { BeginSigninError } from '../../../lib/error-utils'; import { FinishOAuthFlowHandler } from '../../../lib/oauth/hooks'; import { SensitiveData } from '../../../lib/sensitive-data-client'; -import { MozServices } from '../../../lib/types'; import { SigninIntegration, SigninLocationState } from '../interfaces'; export type SigninRecoveryCodeProps = { finishOAuthFlowHandler: FinishOAuthFlowHandler; integration: SigninIntegration; - serviceName?: MozServices; signinState: SigninLocationState; submitRecoveryCode: SubmitRecoveryCode; + lastFourPhoneDigits?: string; } & SensitiveData.AuthData; export type SubmitRecoveryCode = ( diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx new file mode 100644 index 00000000000..ab88bd79ac8 --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx @@ -0,0 +1,211 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useCallback, useEffect } from 'react'; +import { RouteComponentProps, useLocation } from '@reach/router'; +import SigninRecoveryPhone from '.'; +import { SigninLocationState } from '../interfaces'; +import { getSigninState, handleNavigation } from '../utils'; +import { + Integration, + isWebIntegration, + useAuthClient, + useFtlMsgResolver, + useSensitiveDataClient, +} from '../../../models'; +import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; +import { + getHandledError, + getLocalizedErrorMessage, +} from '../../../lib/error-utils'; +import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; +import { + useFinishOAuthFlowHandler, + useOAuthKeysCheck, +} from '../../../lib/oauth/hooks'; +import { SensitiveData } from '../../../lib/sensitive-data-client'; +import OAuthDataError from '../../../components/OAuthDataError'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; +import { storeAccountData } from '../../../lib/storage-utils'; +import { useWebRedirect } from '../../../lib/hooks/useWebRedirect'; + +interface SigninRecoveryPhoneContainerProps { + integration: Integration; +} + +interface SigninRecoveryPhoneLocationState extends SigninLocationState { + signinState: SigninLocationState; + lastFourPhoneDigits: string; +} + +const SigninRecoveryPhoneContainer = ({ + integration, +}: SigninRecoveryPhoneContainerProps & RouteComponentProps) => { + const authClient = useAuthClient(); + const ftlMsgResolver = useFtlMsgResolver(); + const location = useLocation() as ReturnType & { + state: SigninRecoveryPhoneLocationState; + }; + const signinState = getSigninState( + location.state?.signinState as SigninLocationState + ); + const lastFourPhoneDigits = location.state?.lastFourPhoneDigits; + const navigateWithQuery = useNavigateWithQuery(); + + useEffect(() => { + if (!signinState || !signinState.sessionToken || !lastFourPhoneDigits) { + navigateWithQuery('/signin'); + return; + } + }); + + const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler( + authClient, + integration + ); + const sensitiveDataClient = useSensitiveDataClient(); + const { keyFetchToken, unwrapBKey } = + sensitiveDataClient.getDataType(SensitiveData.Key.Auth) || {}; + + const { oAuthKeysCheckError } = useOAuthKeysCheck( + integration, + keyFetchToken, + unwrapBKey + ); + + // TODO in FXA-10374 handle empty state from page reload + // consider not passing lastFourPhoneDigits as location state + + const webRedirectCheck = useWebRedirect(integration.data.redirectTo); + + const redirectTo = + isWebIntegration(integration) && webRedirectCheck?.isValid + ? integration.data.redirectTo + : ''; + + const onSuccessNavigate = useCallback(async () => { + if (!signinState) { + return; + } + + const navigationOptions = { + email: signinState.email, + signinData: { + uid: signinState.uid, + sessionToken: signinState.sessionToken, + verificationReason: signinState.verificationReason, + verificationMethod: signinState.verificationMethod, + verified: true, + keyFetchToken, + }, + unwrapBKey, + integration, + finishOAuthFlowHandler, + redirectTo, + queryParams: location.search, + handleFxaLogin: true, + handleFxaOAuthLogin: true, + }; + + const { error } = await handleNavigation(navigationOptions); + // TODO handle error + // if (error) { + // setBannerErrorMessage(getLocalizedErrorMessage(ftlMsgResolver, error)); + // } + }, [ + integration, + finishOAuthFlowHandler, + keyFetchToken, + location.search, + redirectTo, + signinState, + unwrapBKey, + ]); + + const handleSuccess = async () => { + if (!signinState) { + return; + } + + storeAccountData({ + sessionToken: signinState.sessionToken, + email: signinState.email, + uid: signinState.uid, + // Update verification status of stored current account + verified: true, + }); + + await onSuccessNavigate(); + }; + + const verifyCode = async (otpCode: string) => { + if (!signinState) { + return; + } + try { + await authClient.recoveryPhoneSigninConfirm( + signinState.sessionToken, + otpCode + ); + await handleSuccess(); + return; + } catch (error) { + const handledError = getHandledError(error); + if (handledError.error.errno === AuthUiErrors.INVALID_TOKEN.errno) { + navigateWithQuery('/signin', { replace: true }); + return; + } + // TODO verify that we have the required error message for incorrect code + return getLocalizedErrorMessage(ftlMsgResolver, handledError.error); + } + }; + + const resendCode = async () => { + if (!signinState) { + return; + } + try { + await authClient.recoveryPhoneSendCode(signinState.sessionToken); + return; + } catch (error) { + const handledError = getHandledError(error); + if (handledError.error.errno === AuthUiErrors.INVALID_TOKEN.errno) { + navigateWithQuery('/signin', { replace: true }); + return; + } + // if smsSendRateLimitExceeded or backendServiceFailure, prompt the user to try later or use backup authentication codes + if ( + handledError.error.errno === + AuthUiErrors.BACKEND_SERVICE_FAILURE.errno || + handledError.error.errno === + AuthUiErrors.SMS_SEND_RATE_LIMIT_EXCEEDED.errno + ) { + return ftlMsgResolver.getMsg( + 'signin-recovery-phone-send-code-failure', + 'There was a problem sending a code to your recovery phone. Please try again later or use your backup authentication codes.' + ); + } + return getLocalizedErrorMessage(ftlMsgResolver, handledError.error); + } + }; + + if (oAuthDataError) { + return ; + } + if (oAuthKeysCheckError) { + return ; + } + + return ( + + ); +}; + +export default SigninRecoveryPhoneContainer; diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/en.ftl b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/en.ftl new file mode 100644 index 00000000000..2890d8b3ff3 --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/en.ftl @@ -0,0 +1,20 @@ +## SigninRecoveryPhone page + +signin-recovery-phone-flow-heading = Sign in + +# A recovery code in context of this page is a one time code sent to the user's phone +signin-recovery-phone-heading = Enter recovery code + +# Text that explains the user should check their phone for a recovery code +# $maskedPhoneNumber - The users masked phone number +signin-recovery-phone-instruction = A six-digit code was sent to { $maskedPhoneNumber } by text message. This code expires after 5 minutes. + +signin-recovery-phone-input-label = Enter 6-digit code + +signin-recovery-phone-code-submit-button = Confirm + +signin-recovery-phone-resend-code-button = Resend code +signin-recovery-phone-resend-success = Code sent + +# links to https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication +signin-recovery-phone-locked-out-link = Are you locked out? diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.stories.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.stories.tsx similarity index 51% rename from packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.stories.tsx rename to packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.stories.tsx index 8edac4e5ad9..48d2f7134ce 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.stories.tsx @@ -5,24 +5,21 @@ import React from 'react'; import { Meta } from '@storybook/react'; import { withLocalization } from 'fxa-react/lib/storybooks'; -import ConfirmRecoveryCode from '.'; import { Subject } from './mocks'; +import SigninRecoveryPhone from '.'; export default { - title: 'Pages/Signin/SigninRecoveryMethod/Phone/ConfirmRecoveryCode', - component: ConfirmRecoveryCode, + title: 'Pages/Signin/SigninRecoveryPhone', + component: SigninRecoveryPhone, decorators: [withLocalization], } as Meta; export const Basic = () => ; -export const WithErrorMessage = () => ( - Promise.resolve()} - resendCode={() => Promise.resolve()} - clearBanners={() => {}} - setErrorMessage={() => {}} +export const WithErrorMessages = () => ( + Promise.resolve('Error message')} + resendCode={() => Promise.resolve('Error message')} /> ); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.test.tsx similarity index 73% rename from packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.test.tsx rename to packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.test.tsx index 193977890ba..b97621683d9 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.test.tsx @@ -2,19 +2,14 @@ import React from 'react'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import { screen, act } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import SigninRecoveryPhoneCodeConfirm from './index'; +import SigninRecoveryPhone from './index'; -describe('SigninRecoveryPhoneCodeConfirm', () => { +describe('SigninRecoveryPhone', () => { const mockVerifyCode = jest.fn(() => Promise.resolve()); const mockResendCode = jest.fn(() => Promise.resolve()); - const mockClearBanners = jest.fn(); - const mockSetErrorMessage = jest.fn(); const defaultProps = { - clearBanners: mockClearBanners, - maskedPhoneNumber: '••••••1234', - errorMessage: '', - setErrorMessage: mockSetErrorMessage, + lastFourPhoneDigits: '1234', verifyCode: mockVerifyCode, resendCode: mockResendCode, }; @@ -24,9 +19,7 @@ describe('SigninRecoveryPhoneCodeConfirm', () => { }); it('renders as expected', async () => { - renderWithLocalizationProvider( - - ); + renderWithLocalizationProvider(); expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); expect( @@ -50,9 +43,7 @@ describe('SigninRecoveryPhoneCodeConfirm', () => { }); it('submits with valid code', async () => { - renderWithLocalizationProvider( - - ); + renderWithLocalizationProvider(); const input = screen.getByRole('textbox'); await act(async () => { @@ -64,9 +55,7 @@ describe('SigninRecoveryPhoneCodeConfirm', () => { }); it('handles resend code', async () => { - renderWithLocalizationProvider( - - ); + renderWithLocalizationProvider(); await act(async () => { await userEvent.click( @@ -78,9 +67,7 @@ describe('SigninRecoveryPhoneCodeConfirm', () => { }); it('handles `Are you locked out?` link', async () => { - renderWithLocalizationProvider( - - ); + renderWithLocalizationProvider(); const link = screen.getByRole('link', { name: 'Are you locked out? Opens in new window', diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.tsx similarity index 55% rename from packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.tsx rename to packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.tsx index fb88e1119ce..0ccead17253 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/index.tsx @@ -14,45 +14,79 @@ import { HeadingPrimary } from '../../../components/HeadingPrimary'; import LinkExternal from 'fxa-react/components/LinkExternal'; import ButtonBack from '../../../components/ButtonBack'; -export type SigninRecoveryPhoneCodeConfirmProps = { - clearBanners?: () => void; - maskedPhoneNumber: string; - errorMessage: string; - setErrorMessage: React.Dispatch>; - verifyCode: (code: string) => Promise; - resendCode: () => Promise; +export type SigninRecoveryPhoneProps = { + lastFourPhoneDigits: string; + verifyCode: (code: string) => Promise; + resendCode: () => Promise; }; -const SigninRecoveryPhoneCodeConfirm = ({ - clearBanners, - maskedPhoneNumber, - errorMessage, - setErrorMessage, +const SigninRecoveryPhone = ({ + lastFourPhoneDigits, verifyCode, resendCode, -}: SigninRecoveryPhoneCodeConfirmProps & RouteComponentProps) => { +}: SigninRecoveryPhoneProps & RouteComponentProps) => { + const [errorMessage, setErrorMessage] = React.useState(''); + const [showResendSuccessBanner, setShowResendSuccessBanner] = + React.useState(false); const ftlMsgResolver = useFtlMsgResolver(); + const maskedPhoneNumber = `+1-•••-${lastFourPhoneDigits}`; + const spanElement = {maskedPhoneNumber}; + const clearBanners = () => { + setErrorMessage(''); + setShowResendSuccessBanner(false); + }; + + const handleVerifyCode = async (code: string) => { + const localizedErrorMessage = await verifyCode(code); + if (localizedErrorMessage) { + setErrorMessage(localizedErrorMessage); + return; + } + }; + + const handleResendCode = async () => { + clearBanners(); + const localizedErrorMessage = (await resendCode()) || ''; + if (localizedErrorMessage) { + setErrorMessage(localizedErrorMessage); + return; + } + setShowResendSuccessBanner(true); + }; + return ( -
+
- - Sign in + + Sign in
{errorMessage && ( )} + {showResendSuccessBanner && ( + // TODO show correct success message + + )} - +

Enter recovery code

@@ -65,31 +99,31 @@ const SigninRecoveryPhoneCodeConfirm = ({ codeLength={6} codeType="numeric" localizedInputLabel={ftlMsgResolver.getMsg( - 'confirm-recovery-code-code-input-group-label', + 'signin-recovery-phone-input-label', 'Enter 6-digit code' )} localizedSubmitButtonText={ftlMsgResolver.getMsg( - 'confirm-recovery-code-otp-submit-button', + 'signin-recovery-phone-code-submit-button', 'Confirm' )} + verifyCode={handleVerifyCode} {...{ clearBanners, errorMessage, setErrorMessage, - verifyCode, }} />
- + - + Promise.resolve(); const mockResendCode = () => Promise.resolve(); @@ -14,22 +12,14 @@ const mockResendCode = () => Promise.resolve(); export const Subject = ({ verifyCode = mockVerifyCode, resendCode = mockResendCode, -}: Partial) => { - const maskedPhoneNumber = MOCK_PHONE_NUMBER; - const [errorMessage, setErrorMessage] = useState(''); - - const clearBanners = () => { - setErrorMessage(''); - }; +}: Partial) => { + const lastFourPhoneDigits = '1234'; return ( - { - const [errorMessage, setErrorMessage] = useState(''); - const [resendErrorMessage, setResendErrorMessage] = useState(''); - const [resendStatus, setResendStatus] = useState( - ResendStatus.none - ); - - const verifyCode = async (otpCode: string) => {}; - const resendCode = async () => {}; - - const clearBanners = () => { - setErrorMessage(''); - setResendErrorMessage(''); - setResendStatus(ResendStatus.none); - }; - - // TODO: get from api - const maskedPhoneNumber = '••••••1234}'; - - return ( - - ); -}; - -export default ConfirmRecoveryCodeContainer; diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/en.ftl b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/en.ftl deleted file mode 100644 index 7e55b3eb000..00000000000 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhoneCodeConfirm/en.ftl +++ /dev/null @@ -1,14 +0,0 @@ -## SigninRecoveryPhoneCodeConfirm page - -recovery-phone-code-confirm-flow-heading = Sign in - -# A recovery code in context of this page is a one time code sent to the user's phone -recovery-phone-code-confirm-with-code-heading = Enter recovery code - -# Text that explains the user should check their phone for a recovery code -# $maskedPhoneNumber - The users masked phone number -recovery-phone-code-confirm-code-instruction = A six-digit code was sent to { $maskedPhoneNumber } by text message. This code expires after 5 minutes. - -recovery-phone-code-confirm-input-group-label = Enter 6-digit code - -recovery-phone-code-confirm-otp-submit-button = Confirm \ No newline at end of file diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx index 667679670bd..f566c46a97b 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useState } from 'react'; import { Link, RouteComponentProps, useLocation } from '@reach/router'; import { FtlMsg, hardNavigate } from 'fxa-react/lib/utils'; -import { useFtlMsgResolver } from '../../../models'; +import { useConfig, useFtlMsgResolver } from '../../../models'; import { logViewEvent } from '../../../lib/metrics'; import { MozServices } from '../../../lib/types'; import AppLayout from '../../../components/AppLayout'; @@ -49,6 +49,7 @@ export const SigninTotpCode = ({ keyFetchToken, unwrapBKey, }: SigninTotpCodeProps & RouteComponentProps) => { + const config = useConfig(); const ftlMsgResolver = useFtlMsgResolver(); const location = useLocation(); @@ -115,6 +116,10 @@ export const SigninTotpCode = ({ } }; + const troubleWithCodeTarget = config.featureFlags?.enableUsing2FABackupPhone + ? 'signin_recovery_choice' + : 'signin_recovery_code'; + return ( @@ -190,7 +195,7 @@ export const SigninTotpCode = ({