feat(fxa-settings): Implement using recovery phone to sign in

Because:

* New feature: recovery phone as recovery method for 2FA during sign in

This commit:

* Hook up new pages to choose recovery method and use recovery phone during sign in

Closes #FXA-10374
This commit is contained in:
Valerie Pomerleau 2025-01-16 19:17:31 -08:00
Родитель 30d65aabf5
Коммит 9b0fbc11fd
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 33A451F0BB2180B4
63 изменённых файлов: 1815 добавлений и 582 удалений

Просмотреть файл

@ -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';

Просмотреть файл

@ -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';

Просмотреть файл

@ -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';

Просмотреть файл

@ -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<MessageInstance> {
const from = this.rotateFromNumber();
try {
const msg = await this.client.messages.create({
to,

Просмотреть файл

@ -2338,7 +2338,7 @@ export default class AuthClient {
* @param code The otp code sent to the user's phone
* @param headers
*/
async recoveryPhoneSignInConfirm(
async recoveryPhoneSigninConfirm(
sessionToken: string,
code: string,
headers?: Headers

Просмотреть файл

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

Просмотреть файл

@ -1133,7 +1133,7 @@ export class AccountHandler {
}
}
// If they just went through the sigin-unblock flow, they have already verified their email.
// If they just went through the signin-unblock flow, they have already verified their email.
// We don't need to force them to do that again, just make a verified session.
if (didSigninUnblock) {
needsVerificationId = false;

Просмотреть файл

@ -781,14 +781,14 @@ const mocks = require('../mocks');
})
.then((emailData) => {
assert.equal(emailData.headers['x-template-name'], 'verify');
const siginToken = emailData.headers['x-verify-code'];
const signinToken = emailData.headers['x-verify-code'];
assert.notEqual(
tokenCode,
siginToken,
signinToken,
'login codes should not match'
);
return client.verifyEmail(siginToken);
return client.verifyEmail(signinToken);
})
.then(() => {
return client.emailStatus();
@ -812,7 +812,5 @@ const mocks = require('../mocks');
assert.ok(keys.wrapKb, 'has wrapKb keys');
});
});
});
});

Просмотреть файл

@ -112,7 +112,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!');
}
@ -185,7 +185,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!');
}

Просмотреть файл

@ -285,7 +285,7 @@ var ERRORS = {
errno: 181,
message: t('Update was rejected, please try again'),
},
INVALID_EXPIRED_SIGNUP_CODE: {
INVALID_EXPIRED_OTP_CODE: {
errno: 183,
message: t('Invalid or expired confirmation code'),
},

Просмотреть файл

@ -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
);
},
'signin_recovery_phone(/)': function () {
this.createReactViewHandler('signin_recovery_phone');
},
'signin_reported(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_reported',

Просмотреть файл

@ -96,7 +96,7 @@ class ConfirmSignupCodeView extends FormView {
})
.catch((err) => {
if (
AuthErrors.is(err, 'INVALID_EXPIRED_SIGNUP_CODE') ||
AuthErrors.is(err, 'INVALID_EXPIRED_OTP_CODE') ||
AuthErrors.is(err, 'OTP_CODE_REQUIRED') ||
AuthErrors.is(err, 'INVALID_OTP_CODE')
) {

Просмотреть файл

@ -229,7 +229,7 @@ describe('views/confirm_signup_code', () => {
});
describe('invalid or expired code error', () => {
const error = AuthErrors.toError('INVALID_EXPIRED_SIGNUP_CODE');
const error = AuthErrors.toError('INVALID_EXPIRED_OTP_CODE');
beforeEach(() => {
sinon

Просмотреть файл

@ -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',

Просмотреть файл

@ -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',

Просмотреть файл

@ -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 ? (
<>
<SigninRecoveryChoiceContainer path="/signin_recovery_choice/*" />
<SigninRecoveryPhoneContainer
path="/signin_recovery_phone/*"
{...{ integration }}
/>
</>
) : (
<>
<Redirect
from="/signin_recovery_choice/*"
to="/signin_recovery_code/*"
noThrow
/>
<Redirect
from="/signin_recovery_phone/*"
to="/signin_recovery_code/*"
noThrow
/>
</>
)}
<SigninRecoveryCodeContainer
path="/signin_recovery_code/*"
{...{ integration, serviceName }}
{...{ integration }}
/>
<SigninReported path="/signin_reported/*" />
<SigninTokenCodeContainer

Просмотреть файл

@ -24,6 +24,7 @@ const FormVerifyTotp = ({
localizedSubmitButtonText,
setErrorMessage,
verifyCode,
gleanDataAttrs,
}: FormVerifyTotpProps) => {
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true);
@ -112,6 +113,11 @@ const FormVerifyTotp = ({
className="cta-primary cta-xl"
disabled={isSubmitDisabled}
title={isSubmitDisabled ? getDisabledButtonTitle() : ''}
{...(gleanDataAttrs && {
'data-glean-id': gleanDataAttrs.id,
'data-glean-label': gleanDataAttrs.label,
'data-glean-type': gleanDataAttrs.type,
})}
>
{localizedSubmitButtonText}
</button>

Просмотреть файл

@ -2,6 +2,8 @@
* 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 { GleanClickEventDataAttrs } from '../../lib/types';
export type FormVerifyTotpProps = {
clearBanners?: () => void;
codeLength: 6 | 8 | 10;
@ -12,6 +14,7 @@ export type FormVerifyTotpProps = {
localizedSubmitButtonText: string;
setErrorMessage: React.Dispatch<React.SetStateAction<string>>;
verifyCode: (code: string) => void;
gleanDataAttrs?: GleanClickEventDataAttrs;
};
export type VerifyTotpFormData = {

Просмотреть файл

@ -49,7 +49,7 @@ describe('ModalVerifySession', () => {
it('renders error messages', async () => {
const error: any = new Error('invalid code');
error.errno = AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE.errno;
error.errno = AuthUiErrors.INVALID_EXPIRED_OTP_CODE.errno;
const session = {
sendVerificationCode: jest.fn().mockResolvedValue(true),
verifySession: jest.fn().mockRejectedValue(error),
@ -74,7 +74,7 @@ describe('ModalVerifySession', () => {
});
expect(screen.getByTestId('tooltip').textContent).toContain(
AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE.message
AuthUiErrors.INVALID_EXPIRED_OTP_CODE.message
);
});

Просмотреть файл

@ -45,11 +45,11 @@ export const ModalVerifySession = ({
try {
await session.verifySession(code);
} catch (e) {
if (e.errno === AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE.errno) {
if (e.errno === AuthUiErrors.INVALID_EXPIRED_OTP_CODE.errno) {
const errorText = l10n.getString(
getErrorFtlId(e),
null,
AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE.message
AuthUiErrors.INVALID_EXPIRED_OTP_CODE.message
);
setErrorText(errorText);
} else {

Просмотреть файл

@ -163,9 +163,13 @@ export const UnitRowTwoStepAuth = () => {
onCtaClick={() => {
navigate(`${SETTINGS_PATH}/recovery_phone/setup`);
}}
onDeleteClick={() => {
navigate(`${SETTINGS_PATH}/recovery_phone/remove`);
}}
// only include the delete option if the user has recovery codes available
{...(count &&
count > 0 && {
onDeleteClick: () => {
navigate(`${SETTINGS_PATH}/recovery_phone/remove`);
},
})}
phoneNumber={recoveryPhone.phoneNumber || ''}
key={2}
/>

Просмотреть файл

@ -47,17 +47,16 @@ export const Settings = ({
integration,
}: { integration: SettingsIntegration } & RouteComponentProps) => {
const config = useConfig();
const { metricsEnabled, hasPassword } = useAccount();
const session = useSession();
const account = useAccount();
const location = useLocation();
useEffect(() => {
if (config.metrics.navTiming.enabled && metricsEnabled) {
if (config.metrics.navTiming.enabled && account.metricsEnabled) {
observeNavigationTiming(config.metrics.navTiming.endpoint);
}
}, [
metricsEnabled,
account.metricsEnabled,
config.metrics.navTiming.enabled,
config.metrics.navTiming.endpoint,
]);
@ -145,6 +144,14 @@ export const Settings = ({
return <AppErrorDialog data-testid="error-dialog" {...{ error }} />;
}
const canAddRecoveryPhone =
account.recoveryPhone.available &&
config.featureFlags?.enableAdding2FABackupPhone === true;
const canRemoveRecoveryPhone =
account.recoveryPhone.phoneNumber &&
account.backupCodes.hasBackupCodes === true;
return (
<SettingsLayout>
<Head />
@ -153,12 +160,12 @@ export const Settings = ({
<PageSettings path="/" />
<PageDisplayName path="/display_name" />
<PageAvatar path="/avatar" />
{hasPassword ? (
{account.hasPassword ? (
<PageRecoveryKeyCreate path="/account_recovery" />
) : (
<Redirect from="/account_recovery" to="/settings" noThrow />
)}
{hasPassword ? (
{account.hasPassword ? (
<>
<PageChangePassword path="/change_password" />
<Redirect
@ -168,10 +175,6 @@ export const Settings = ({
/>
<PageTwoStepAuthentication path="/two_step_authentication" />
<Page2faReplaceRecoveryCodes path="/two_step_authentication/replace_codes" />
{config.featureFlags?.enableAdding2FABackupPhone === true &&
account.recoveryPhone.available === true && (
<PageRecoveryPhoneSetup path="/recovery_phone/setup" />
)}
</>
) : (
<>
@ -202,8 +205,16 @@ export const Settings = ({
{/* NOTE: `/settings/avatar/change` is used to link directly to the avatar page within Sync preferences settings on Firefox browsers */}
<Redirect from="/avatar/change" to="/settings/avatar/" noThrow />
{config.featureFlags?.enableUsing2FABackupPhone === true && (
{canAddRecoveryPhone ? (
<PageRecoveryPhoneSetup path="/recovery_phone/setup" />
) : (
<Redirect from="/recovery_phone/setup" to="/settings" noThrow />
)}
{canRemoveRecoveryPhone ? (
<PageRecoveryPhoneRemove path="/recovery_phone/remove" />
) : (
<Redirect from="/recovery_phone/remove" to="/settings" noThrow />
)}
</ScrollToTop>
</Router>

Просмотреть файл

@ -119,7 +119,7 @@ const ERRORS = {
// We don't currently support sms but still keep the error codes to avoid conflicts
SMS_ID_INVALID: {
errno: 131,
// should not be user facing, not wrapped in t
// This message should not be user-facing or localized
message: 'SMS ID invalid',
},
SMS_REJECTED: {
@ -282,7 +282,7 @@ const ERRORS = {
errno: 181,
message: 'Update was rejected, please try again',
},
INVALID_EXPIRED_SIGNUP_CODE: {
INVALID_EXPIRED_OTP_CODE: {
errno: 183,
message: 'Invalid or expired confirmation code',
version: 2,
@ -315,6 +315,10 @@ const ERRORS = {
errno: 215,
message: 'Recovery phone number does not exist',
},
SMS_SEND_RATE_LIMIT_EXCEEDED: {
errno: 216,
message: 'Client has sent too many requests',
},
RECOVERY_PHONE_REMOVE_MISSING_RECOVERY_CODES: {
errno: 218,
message:

Просмотреть файл

@ -17,8 +17,11 @@ auth-error-125 = The request was blocked for security reasons
auth-error-138-2 = Unconfirmed session
auth-error-139 = Secondary email must be different than your account email
auth-error-155 = TOTP token not found
# Error shown when the user submits an invalid backup authentication code
auth-error-156 = Backup authentication code not found
auth-error-159 = Invalid account recovery key
auth-error-183-2 = Invalid or expired confirmation code
auth-error-203 = System unavailable, try again soon
auth-error-206 = Can not create password, password already set
auth-error-999 = Unexpected error
auth-error-1001 = Login attempt cancelled
@ -30,4 +33,5 @@ auth-error-1011 = Valid email required
auth-error-1031 = You must enter your age to sign up
auth-error-1032 = You must enter a valid age to sign up
auth-error-1054 = Invalid two-step authentication code
auth-error-1056 = Invalid backup authentication code
auth-error-1062 = Invalid redirect

Просмотреть файл

@ -110,7 +110,7 @@ function mockCurrentAccount(
}
let currentSetPasswordProps: SetPasswordProps | undefined;
function mockInlineRecoveryKeySetupModule() {
function mockSetPasswordModule() {
jest
.spyOn(SetPasswordModule, 'default')
.mockImplementation((props: SetPasswordProps) => {
@ -123,7 +123,7 @@ function applyDefaultMocks() {
jest.resetAllMocks();
jest.restoreAllMocks();
mockModelsModule();
mockInlineRecoveryKeySetupModule();
mockSetPasswordModule();
mockCurrentAccount(MOCK_STORED_ACCOUNT);
(useFinishOAuthFlowHandler as jest.Mock).mockImplementation(() => ({
finishOAuthFlowHandler: jest

Просмотреть файл

@ -0,0 +1,173 @@
/* 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 CacheModule from '../../../lib/cache';
import * as SigninRecoveryChoiceModule from './index';
import { waitFor } from '@testing-library/react';
import { LocationProvider } from '@reach/router';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import {
MOCK_MASKED_PHONE_NUMBER,
MOCK_STORED_ACCOUNT,
mockLoadingSpinnerModule,
} from '../../mocks';
import { mockSigninLocationState } from '../mocks';
import SigninRecoveryChoiceContainer from './container';
import AuthClient from 'fxa-auth-client/lib/client';
jest.mock('../../../models', () => {
return {
...jest.requireActual('../../../models'),
useAuthClient: jest.fn(),
};
});
const mockAuthClient = new AuthClient('http://localhost:9000', {
keyStretchVersion: 2,
});
function mockModelsModule({
mockGetRecoveryCodesExist = jest.fn().mockResolvedValue({
hasBackupCodes: true,
count: 3,
}),
mockRecoveryPhoneGet = jest.fn().mockResolvedValue({
exists: true,
phoneNumber: MOCK_MASKED_PHONE_NUMBER,
}),
mockRecoveryPhoneSigninSendCode = jest.fn().mockResolvedValue(true),
}) {
mockAuthClient.getRecoveryCodesExist = mockGetRecoveryCodesExist;
mockAuthClient.recoveryPhoneGet = mockRecoveryPhoneGet;
mockAuthClient.recoveryPhoneSigninSendCode = mockRecoveryPhoneSigninSendCode;
(ModelsModule.useAuthClient as jest.Mock).mockImplementation(
() => mockAuthClient
);
}
function mockSigninRecoveryChoiceModule() {
jest.spyOn(SigninRecoveryChoiceModule, 'default');
}
function mockCache(opts: any = {}, isEmpty = false) {
jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(
isEmpty
? undefined
: {
sessionToken: '123',
...(opts || {}),
}
);
}
const mockLocation = (pathname: string, mockLocationState: Object) => {
return {
...global.window.location,
pathname,
state: mockLocationState,
};
};
const mockNavigate = jest.fn();
function mockReachRouter(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();
mockCache();
mockReachRouter('signin_recovery_choice', mockSigninLocationState);
}
function render() {
renderWithLocalizationProvider(
<LocationProvider>
<SigninRecoveryChoiceContainer />
</LocationProvider>
);
}
describe('SigninRecoveryChoice container', () => {
beforeEach(() => {
applyDefaultMocks();
});
describe('initial state', () => {
it('redirects if page is reached without location state or cached account', async () => {
mockReachRouter(undefined, 'signin_recovery_choice');
mockCache({}, true);
await render();
expect(SigninRecoveryChoiceModule.default).not.toBeCalled();
expect(mockNavigate).toBeCalledWith('/signin');
});
it('redirects if there is no sessionToken', async () => {
mockReachRouter('signin_recovery_choice');
mockCache({ sessionToken: '' });
await render();
expect(SigninRecoveryChoiceModule.default).not.toBeCalled();
expect(mockNavigate).toBeCalledWith('/signin');
});
it('retrieves the session token from local storage if no location state', async () => {
mockReachRouter('signin_recovery_choice', {});
mockCache(MOCK_STORED_ACCOUNT);
await waitFor(() => render());
expect(mockNavigate).not.toBeCalled();
expect(SigninRecoveryChoiceModule.default).toBeCalled();
});
});
describe('fetches recovery method data', () => {
it('fetches recovery codes and phone number successfully', async () => {
render();
await waitFor(() => {
expect(mockAuthClient.getRecoveryCodesExist).toHaveBeenCalled();
expect(mockAuthClient.recoveryPhoneGet).toHaveBeenCalled();
expect(SigninRecoveryChoiceModule.default).toBeCalled();
});
});
it('passes the correct props to the child component', async () => {
render();
await waitFor(() => {
expect(SigninRecoveryChoiceModule.default).toBeCalledWith(
expect.objectContaining({
lastFourPhoneDigits: '1234',
numBackupCodes: 3,
signinState: mockSigninLocationState,
}),
{}
);
});
});
it('handles absence of recovery phone gracefully', async () => {
mockModelsModule({
mockRecoveryPhoneGet: jest.fn().mockResolvedValue({ exists: false }),
});
render();
await waitFor(() => {
expect(mockAuthClient.getRecoveryCodesExist).toHaveBeenCalled();
expect(mockAuthClient.recoveryPhoneGet).toHaveBeenCalled();
expect(SigninRecoveryChoiceModule.default).not.toBeCalled();
expect(mockNavigate).toBeCalledWith('/signin_recovery_code', {
replace: true,
state: { signinState: mockSigninLocationState },
});
});
});
});
});

Просмотреть файл

@ -0,0 +1,127 @@
/* 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 } from '../../../models';
import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery';
import { SigninLocationState } from '../interfaces';
import { getSigninState } from '../utils';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import {
AuthUiErrorNos,
AuthUiErrors,
} from '../../../lib/auth-errors/auth-errors';
export const SigninRecoveryChoiceContainer = (_: RouteComponentProps) => {
const authClient = useAuthClient();
const location = useLocation() as ReturnType<typeof useLocation> & {
state: SigninLocationState;
};
const navigateWithQuery = useNavigateWithQuery();
const signinState = getSigninState(location.state);
const [numBackupCodes, setNumBackupCodes] = useState<number>(0);
const [lastFourPhoneDigits, setLastFourPhoneDigits] = useState<string>('');
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));
// whether or not the user has backup authentication codes,
// go directly to the backup authentication codes page if they don't have a phone number
// do not render the choice screen
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;
}
if (phoneNumber && (!count || count === 0)) {
navigateWithQuery('/signin_recovery_phone', {
state: { signinState, lastFourPhoneDigits },
// ensure back button on signin_recovery_code page skips choice page and returns to signin_totp_code
replace: true,
});
}
return;
} catch (err) {
if (err.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, lastFourPhoneDigits, signinState, navigateWithQuery]);
const handlePhoneChoice = async () => {
if (!signinState) {
return;
}
try {
await authClient.recoveryPhoneSigninSendCode(signinState.sessionToken);
return;
} catch (err) {
if (err.errno && AuthUiErrorNos[err.errno]) {
if (err.errno === AuthUiErrors.INVALID_TOKEN.errno) {
navigateWithQuery('/signin');
return;
}
return err;
}
return AuthUiErrors.UNEXPECTED_ERROR;
}
};
if (loading) {
return <LoadingSpinner fullScreen />;
}
if (!signinState || !lastFourPhoneDigits) {
return <LoadingSpinner fullScreen />;
}
return (
<SigninRecoveryChoice
{...{
handlePhoneChoice,
lastFourPhoneDigits,
numBackupCodes,
signinState,
}}
/>
);
};
export default SigninRecoveryChoiceContainer;

Просмотреть файл

@ -6,6 +6,9 @@ signin-recovery-method-header = Sign in
signin-recovery-method-subheader = Choose a recovery method
signin-recovery-method-details = Lets make sure its 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-error-heading = There was a problem sending a code to your recovery phone
signin-recovery-phone-send-code-error-description = Please try again later or use your backup authentication codes.

Просмотреть файл

@ -0,0 +1,76 @@
/* 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 from 'react';
import { Meta } from '@storybook/react';
import SigninRecoveryChoice from '.';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { MOCK_SIGNIN_LOCATION_STATE } from './mocks';
import { LocationProvider } from '@reach/router';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
export default {
title: 'Pages/Signin/SigninRecoveryChoice',
component: SigninRecoveryChoice,
decorators: [withLocalization],
} as Meta;
export const Default = () => (
<LocationProvider>
<SigninRecoveryChoice
handlePhoneChoice={() => Promise.resolve()}
lastFourPhoneDigits="1234"
numBackupCodes={4}
signinState={MOCK_SIGNIN_LOCATION_STATE}
/>
</LocationProvider>
);
export const WithSMSSendRateLimitExceeded = () => (
<LocationProvider>
<SigninRecoveryChoice
handlePhoneChoice={() =>
Promise.resolve(AuthUiErrors.SMS_SEND_RATE_LIMIT_EXCEEDED)
}
lastFourPhoneDigits="1234"
numBackupCodes={4}
signinState={MOCK_SIGNIN_LOCATION_STATE}
/>
</LocationProvider>
);
export const WithUnexpectedError = () => (
<LocationProvider>
<SigninRecoveryChoice
handlePhoneChoice={() => Promise.resolve(AuthUiErrors.UNEXPECTED_ERROR)}
lastFourPhoneDigits="1234"
numBackupCodes={4}
signinState={MOCK_SIGNIN_LOCATION_STATE}
/>
</LocationProvider>
);
export const WithBackendServiceFailure = () => (
<LocationProvider>
<SigninRecoveryChoice
handlePhoneChoice={() =>
Promise.resolve(AuthUiErrors.BACKEND_SERVICE_FAILURE)
}
lastFourPhoneDigits="1234"
numBackupCodes={4}
signinState={MOCK_SIGNIN_LOCATION_STATE}
/>
</LocationProvider>
);
export const WithThrottlingError = () => (
<LocationProvider>
<SigninRecoveryChoice
handlePhoneChoice={() => Promise.resolve(AuthUiErrors.THROTTLED)}
lastFourPhoneDigits="1234"
numBackupCodes={4}
signinState={MOCK_SIGNIN_LOCATION_STATE}
/>
</LocationProvider>
);

Просмотреть файл

@ -0,0 +1,141 @@
/* 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 ReachRouterModule from '@reach/router';
import React from 'react';
import { LocationProvider } from '@reach/router';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SigninRecoveryChoice from '.';
import { MOCK_SIGNIN_LOCATION_STATE } from './mocks';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
function renderSigninRecoveryChoice(overrides = {}) {
const defaultProps = {
handlePhoneChoice: jest.fn(),
lastFourPhoneDigits: '1234',
numBackupCodes: 4,
signinState: MOCK_SIGNIN_LOCATION_STATE,
...overrides,
};
renderWithLocalizationProvider(
<LocationProvider>
<SigninRecoveryChoice {...defaultProps} />
</LocationProvider>
);
}
const mockLocation = (pathname: string, mockLocationState: Object) => {
return {
...global.window.location,
pathname,
state: mockLocationState,
};
};
const mockNavigate = jest.fn();
function mockReachRouter(pathname = '', mockLocationState = {}) {
mockNavigate.mockReset();
jest.spyOn(ReachRouterModule, 'useNavigate').mockReturnValue(mockNavigate);
jest
.spyOn(ReachRouterModule, 'useLocation')
.mockImplementation(() => mockLocation(pathname, mockLocationState));
}
describe('SigninRecoveryChoice', () => {
it('renders as expected', () => {
renderSigninRecoveryChoice();
expect(
screen.getByRole('heading', { name: 'Sign in', level: 1 })
).toBeInTheDocument();
expect(
screen.getByRole('heading', {
name: 'Choose a recovery method',
level: 2,
})
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
expect(
within(
screen.getByRole('group').querySelector('legend') as HTMLLegendElement
).getByText('Choose a recovery method')
).toBeInTheDocument();
expect(
screen.getByText('Lets make sure its you using your recovery methods.')
).toBeInTheDocument();
expect(screen.getByLabelText(/Recovery phone/i)).toBeInTheDocument();
expect(screen.getByLabelText(/••••••1234/i)).toBeInTheDocument();
expect(
screen.getByLabelText(/Backup authentication codes/i)
).toBeInTheDocument();
});
it('calls handlePhoneChoice when Recovery phone option is selected', async () => {
mockReachRouter('signin_recovery_choice', MOCK_SIGNIN_LOCATION_STATE);
const user = userEvent.setup();
const mockHandlePhoneChoice = jest.fn();
renderSigninRecoveryChoice({ handlePhoneChoice: mockHandlePhoneChoice });
user.click(screen.getByLabelText(/Recovery phone/i));
user.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(mockHandlePhoneChoice).toHaveBeenCalled();
});
await waitFor(() => {
expect(mockNavigate).toBeCalledWith('/signin_recovery_phone', {
state: {
signinState: MOCK_SIGNIN_LOCATION_STATE,
lastFourPhoneDigits: '1234',
},
});
});
});
it('displays an error banner when handlePhoneChoice fails', async () => {
mockReachRouter('signin_recovery_choice', MOCK_SIGNIN_LOCATION_STATE);
const user = userEvent.setup();
const mockHandlePhoneChoice = jest
.fn()
.mockResolvedValueOnce(AuthUiErrors.BACKEND_SERVICE_FAILURE);
renderSigninRecoveryChoice({ handlePhoneChoice: mockHandlePhoneChoice });
user.click(screen.getByLabelText(/Recovery phone/i));
user.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'There was a problem sending a code to your recovery phone'
);
expect(mockNavigate).not.toBeCalled();
});
});
it('navigates to the recovery code page when Backup authentication codes option is selected', async () => {
mockReachRouter('signin_recovery_choice', MOCK_SIGNIN_LOCATION_STATE);
const user = userEvent.setup();
renderSigninRecoveryChoice();
user.click(screen.getByLabelText(/Backup authentication codes/i));
user.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(mockNavigate).toBeCalledWith('/signin_recovery_code', {
state: {
signinState: MOCK_SIGNIN_LOCATION_STATE,
lastFourPhoneDigits: '1234',
},
});
});
});
});

Просмотреть файл

@ -0,0 +1,159 @@
/* 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 from 'react';
import { FtlMsg } from 'fxa-react/lib/utils';
import AppLayout from '../../../components/AppLayout';
import { HeadingPrimary } from '../../../components/HeadingPrimary';
import FormChoice, {
CHOICES,
FormChoiceData,
FormChoiceOption,
} from '../../../components/FormChoice';
import { useFtlMsgResolver } from '../../../models';
import {
BackupAuthenticationCodesImage,
BackupRecoveryPhoneSmsImage,
} from '../../../components/images';
import ButtonBack from '../../../components/ButtonBack';
import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery';
import Banner from '../../../components/Banner';
import { SigninLocationState } from '../interfaces';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
import {
AuthUiError,
AuthUiErrors,
} from '../../../lib/auth-errors/auth-errors';
export type SigninRecoveryChoiceProps = {
handlePhoneChoice: () => Promise<AuthUiError | void>;
lastFourPhoneDigits: string;
numBackupCodes: number;
signinState: SigninLocationState;
};
const SigninRecoveryChoice = ({
handlePhoneChoice,
lastFourPhoneDigits,
numBackupCodes,
signinState,
}: SigninRecoveryChoiceProps) => {
const [errorBannerMessage, setErrorBannerMessage] = React.useState('');
const [errorBannerDescription, setErrorBannerDescription] =
React.useState('');
const ftlMsgResolver = useFtlMsgResolver();
const navigateWithQuery = useNavigateWithQuery();
const generalSendCodeErrorHeading = ftlMsgResolver.getMsg(
'signin-recovery-phone-send-code-error-heading',
'There was a problem sending a code to your recovery phone'
);
const generalSendCodeErrorDescription = ftlMsgResolver.getMsg(
'signin-recovery-phone-send-code-error-description',
'Please try again later or use your backup authentication codes.'
);
const handlePhoneChoiceError = (error: AuthUiError) => {
if (
error === AuthUiErrors.BACKEND_SERVICE_FAILURE ||
error === AuthUiErrors.SMS_SEND_RATE_LIMIT_EXCEEDED ||
error === AuthUiErrors.UNEXPECTED_ERROR
) {
setErrorBannerMessage(generalSendCodeErrorHeading);
setErrorBannerDescription(generalSendCodeErrorDescription);
return;
}
setErrorBannerMessage(getLocalizedErrorMessage(ftlMsgResolver, error));
};
const onSubmit = async ({ choice }: FormChoiceData) => {
setErrorBannerMessage('');
setErrorBannerDescription('');
switch (choice) {
case CHOICES.phone:
const error = await handlePhoneChoice();
if (error) {
handlePhoneChoiceError(error);
return;
}
navigateWithQuery('/signin_recovery_phone', {
state: { signinState, lastFourPhoneDigits },
});
break;
case CHOICES.code:
navigateWithQuery('/signin_recovery_code', {
state: { signinState, lastFourPhoneDigits },
});
break;
}
};
const formChoices: FormChoiceOption[] = [
{
id: 'recovery-choice-phone',
value: CHOICES.phone,
image: <BackupRecoveryPhoneSmsImage />,
localizedChoiceTitle: ftlMsgResolver.getMsg(
'signin-recovery-method-phone',
'Recovery phone'
),
// This doesn't need localization
localizedChoiceInfo: `••••••${lastFourPhoneDigits}`,
},
{
id: 'recovery-choice-code',
value: CHOICES.code,
image: <BackupAuthenticationCodesImage />,
localizedChoiceTitle: ftlMsgResolver.getMsg(
'signin-recovery-method-code-v2',
'Backup authentication codes'
),
localizedChoiceInfo: ftlMsgResolver.getMsg(
'signin-recovery-method-code-info',
`${numBackupCodes} codes remaining`,
{ numberOfCodes: numBackupCodes }
),
},
];
return (
<AppLayout>
<div className="relative flex items-center mb-5">
<ButtonBack />
<FtlMsg id="signin-recovery-method-header">
<HeadingPrimary marginClass="">Sign in</HeadingPrimary>
</FtlMsg>
</div>
{errorBannerMessage && (
<Banner
type="error"
content={{
localizedHeading: errorBannerMessage,
localizedDescription: errorBannerDescription,
}}
/>
)}
<FormChoice {...{ legendEl, onSubmit, formChoices }} />
</AppLayout>
);
};
const legendEl = (
<>
<legend>
<FtlMsg id="signin-recovery-method-subheader">
<h2 className="card-header">Choose a recovery method</h2>
</FtlMsg>
</legend>
<FtlMsg id="signin-recovery-method-details">
<p className="pt-2 mb-8">
Lets make sure its you using your recovery methods.
</p>
</FtlMsg>
</>
);
export default SigninRecoveryChoice;

Просмотреть файл

@ -0,0 +1,12 @@
/* 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 { 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,
};

Просмотреть файл

@ -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 {
@ -119,7 +118,9 @@ function applyDefaultMocks() {
mockLoadingSpinnerModule();
mockReactUtilsModule();
mockCache();
mockReachRouter(undefined, 'signin_recovery_code', mockSigninLocationState);
mockReachRouter(undefined, 'signin_recovery_code', {
signinState: mockSigninLocationState,
});
mockWebIntegration();
resetMockSensitiveDataClient();
}
@ -134,7 +135,6 @@ function render(mocks: Array<MockedResponse>) {
<SigninRecoveryCodeContainer
{...{
integration,
serviceName: MozServices.Default,
}}
/>
</LocationProvider>

Просмотреть файл

@ -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,
@ -25,30 +24,36 @@ import { ConsumeRecoveryCodeResponse, SubmitRecoveryCode } from './interfaces';
import OAuthDataError from '../../../components/OAuthDataError';
import { getHandledError } from '../../../lib/error-utils';
import { SensitiveData } from '../../../lib/sensitive-data-client';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery';
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<typeof useLocation> & {
state: SigninLocationState;
};
const signinState = getSigninState(location.state);
const location =
(useLocation() as ReturnType<typeof useLocation> & {
state: SigninRecoveryCodeLocationState;
}) || {};
const navigateWithQuery = useNavigateWithQuery();
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 +69,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 } },
});
@ -78,6 +83,26 @@ export const SigninRecoveryCodeContainer = ({
[consumeRecoveryCode]
);
const navigateToRecoveryPhone = async () => {
if (!signinState) {
return;
}
try {
await authClient.recoveryPhoneSigninSendCode(signinState.sessionToken);
navigateWithQuery('/signin_recovery_phone', {
state: { signinState, lastFourPhoneDigits },
});
return;
} catch (error) {
const { error: handledError } = getHandledError(error);
if (handledError.errno === AuthUiErrors.INVALID_TOKEN.errno) {
navigateWithQuery('/signin');
return;
}
return handledError;
}
};
if (oAuthDataError) {
return <OAuthDataError error={oAuthDataError} />;
}
@ -95,10 +120,11 @@ export const SigninRecoveryCodeContainer = ({
{...{
finishOAuthFlowHandler,
integration,
serviceName,
keyFetchToken,
lastFourPhoneDigits,
navigateToRecoveryPhone,
signinState,
submitRecoveryCode,
keyFetchToken,
unwrapBKey,
}}
/>

Просмотреть файл

@ -5,12 +5,12 @@
signin-recovery-code-heading = Sign in
signin-recovery-code-sub-heading = Enter backup authentication code
signin-recovery-code-instruction-v2 = Enter one of the one-time use backup authentication codes you saved during two-step authentication setup.
signin-recovery-code-input-label-v2 = Enter 10-character code
# codes here refers to backup authentication codes
signin-recovery-code-instruction-v3 = Enter one of the one-time-use codes you saved when you set up two-step authentication.
# 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?
@ -19,4 +19,5 @@ signin-recovery-code-required-error = Backup authentication code required
# Message to user after they were redirected to the Mozilla account sign-in page in a new browser
# tab. Firefox will attempt to send the user back to their original tab to use an email mask after
# they successfully sign in or sign up for a Mozilla account to receive a free email mask.
signin-recovery-code-desktop-relay = { -brand-firefox } will try sending you back to use an email mask after you sign in.
signin-recovery-code-use-phone-failure = There was a problem sending a code to your recovery phone
signin-recovery-code-use-phone-failure-description = Please try again later.

Просмотреть файл

@ -5,14 +5,14 @@
import React from 'react';
import SigninRecoveryCode from '.';
import { Meta } from '@storybook/react';
import { MozServices } from '../../../lib/types';
import { action } from '@storybook/addon-actions';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { LocationProvider } from '@reach/router';
import { mockSigninLocationState } from '../mocks';
import { mockFinishOAuthFlowHandler } from '../../mocks';
import { createMockOAuthNativeIntegration, mockWebIntegration } from './mocks';
import { BeginSigninError } from '../../../lib/error-utils';
import { BeginSigninError, HandledError } from '../../../lib/error-utils';
export default {
title: 'Pages/Signin/SigninRecoveryCode',
@ -29,6 +29,7 @@ export default {
const mockSubmitSuccess = () =>
Promise.resolve({ data: { consumeRecoveryCode: { remaining: 3 } } });
const mockCodeError = () =>
Promise.resolve({
error: AuthUiErrors.INVALID_RECOVERY_CODE as BeginSigninError,
@ -43,6 +44,10 @@ export const Default = () => (
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockWebIntegration}
navigateToRecoveryPhone={() => {
action('handleNavigation')();
return Promise.resolve();
}}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitSuccess}
/>
@ -52,16 +57,10 @@ export const WithOAuthDesktopServiceRelay = () => (
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={createMockOAuthNativeIntegration(false)}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitSuccess}
/>
);
export const WithServiceName = () => (
<SigninRecoveryCode
serviceName={MozServices.MozillaVPN}
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockWebIntegration}
navigateToRecoveryPhone={() => {
action('handleNavigation')();
return Promise.resolve();
}}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitSuccess}
/>
@ -71,6 +70,7 @@ export const WithCodeErrorOnSubmit = () => (
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockWebIntegration}
navigateToRecoveryPhone={() => Promise.resolve()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockCodeError}
/>
@ -80,7 +80,42 @@ export const WithBannerErrorOnSubmit = () => (
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockWebIntegration}
navigateToRecoveryPhone={() => {
action('handleNavigation')();
return Promise.resolve();
}}
signinState={mockSigninLocationState}
submitRecoveryCode={mockOtherError}
/>
);
export const WithRecoveryPhoneSuccessNav = () => (
<LocationProvider>
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockWebIntegration}
lastFourPhoneDigits="1234"
navigateToRecoveryPhone={() => {
action('handleNavigation')();
return Promise.resolve();
}}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitSuccess}
/>
</LocationProvider>
);
export const WithRecoveryPhoneErrorNav = () => (
<LocationProvider>
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockWebIntegration}
lastFourPhoneDigits="1234"
navigateToRecoveryPhone={() =>
Promise.resolve(AuthUiErrors.UNEXPECTED_ERROR as HandledError)
}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitSuccess}
/>
</LocationProvider>
);

Просмотреть файл

@ -18,6 +18,7 @@ import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { OAUTH_ERRORS } from '../../../lib/oauth';
import { tryAgainError } from '../../../lib/oauth/hooks';
import { mockOAuthNativeIntegration } from '../SigninTotpCode/mocks';
import userEvent from '@testing-library/user-event';
jest.mock('../../../lib/glean', () => ({
__esModule: true,
@ -48,6 +49,7 @@ describe('PageSigninRecoveryCode', () => {
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockIntegration}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCode}
/>
@ -62,26 +64,69 @@ describe('PageSigninRecoveryCode', () => {
);
screen.getByRole('img', { name: 'Document that contains hidden text.' });
screen.getByText(
'Enter one of the one-time use backup authentication codes you saved during two-step authentication setup.'
'Enter one of the one-time-use codes you saved when you set up two-step authentication.'
);
screen.getByRole('textbox', {
name: 'Enter 10-character code',
});
screen.getByRole('button', { name: 'Confirm' });
screen.getByRole('link', { name: 'Back' });
screen.getByRole('button', { name: 'Back' });
screen.getByRole('link', {
name: /Are you locked out?/,
name: /Are you locked out/i,
});
expect(screen.queryByText(serviceRelayText)).not.toBeInTheDocument();
});
it('has expected glean click events', async () => {
const mockSubmitRecoveryCode = jest.fn();
renderWithLocalizationProvider(
<LocationProvider>
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockIntegration}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCode}
lastFourPhoneDigits="1234"
/>
</LocationProvider>
);
const user = userEvent.setup();
await waitFor(() =>
user.type(screen.getByRole('textbox'), MOCK_RECOVERY_CODE)
);
expect(screen.getByRole('button', { name: /Confirm/i })).toHaveAttribute(
'data-glean-id',
'login_backup_codes_submit'
);
const phoneLink = screen.getByRole('button', {
name: /Use recovery phone/i,
});
expect(phoneLink).toHaveAttribute(
'data-glean-id',
'login_backup_codes_phone_instead'
);
const lockedOutLink = screen.getByRole('link', {
name: /Are you locked out/i,
});
expect(lockedOutLink).toHaveAttribute(
'data-glean-id',
'login_backup_codes_locked_out_link'
);
});
it('renders expected text when service=relay', () => {
renderWithLocalizationProvider(
<LocationProvider>
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockOAuthNativeIntegration(false)}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={jest.fn()}
/>
@ -98,6 +143,7 @@ describe('PageSigninRecoveryCode', () => {
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockIntegration}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCode}
/>
@ -115,6 +161,7 @@ describe('PageSigninRecoveryCode', () => {
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockIntegration}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCode}
/>
@ -145,6 +192,7 @@ describe('PageSigninRecoveryCode', () => {
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockIntegration}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCode}
/>
@ -168,6 +216,7 @@ describe('PageSigninRecoveryCode', () => {
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockIntegration}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCodeWithError}
/>
@ -196,6 +245,7 @@ describe('PageSigninRecoveryCode', () => {
.fn()
.mockReturnValueOnce(tryAgainError())}
integration={createMockSigninOAuthIntegration()}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCode}
/>
@ -224,6 +274,7 @@ describe('PageSigninRecoveryCode', () => {
<SigninRecoveryCode
finishOAuthFlowHandler={mockFinishOAuthFlowHandler}
integration={mockIntegration}
navigateToRecoveryPhone={jest.fn()}
signinState={mockSigninLocationState}
submitRecoveryCode={mockSubmitRecoveryCodeWithError}
/>

Просмотреть файл

@ -3,7 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useCallback, useEffect, useState } from 'react';
import { Link, RouteComponentProps, useLocation } from '@reach/router';
import { RouteComponentProps, useLocation } from '@reach/router';
import { FtlMsg } from 'fxa-react/lib/utils';
import { isWebIntegration, useFtlMsgResolver } from '../../../models';
import { BackupCodesImage } from '../../../components/images';
@ -23,16 +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,
keyFetchToken,
lastFourPhoneDigits,
navigateToRecoveryPhone,
signinState,
submitRecoveryCode,
keyFetchToken,
unwrapBKey,
}: SigninRecoveryCodeProps & RouteComponentProps) => {
useEffect(() => {
@ -41,6 +44,8 @@ const SigninRecoveryCode = ({
const [codeErrorMessage, setCodeErrorMessage] = useState<string>('');
const [bannerErrorMessage, setBannerErrorMessage] = useState<string>('');
const [bannerErrorDescription, setBannerErrorDescription] =
useState<string>('');
const ftlMsgResolver = useFtlMsgResolver();
const localizedCustomCodeRequiredMessage = ftlMsgResolver.getMsg(
'signin-recovery-code-required-error',
@ -68,6 +73,12 @@ const SigninRecoveryCode = ({
const { email, sessionToken, uid, verificationMethod, verificationReason } =
signinState;
const clearBanners = () => {
setBannerErrorMessage('');
setBannerErrorDescription('');
setCodeErrorMessage('');
};
const onSuccessNavigate = useCallback(async () => {
const navigationOptions = {
email,
@ -113,8 +124,7 @@ const SigninRecoveryCode = ({
);
const onSubmit = async (code: string) => {
setCodeErrorMessage('');
setBannerErrorMessage('');
clearBanners();
GleanMetrics.loginBackupCode.submit();
if (code.length !== 10 || !isBase32Crockford(code)) {
@ -148,16 +158,52 @@ const SigninRecoveryCode = ({
}
};
const handleNavigateToRecoveryPhone = async () => {
clearBanners();
const handledError = await navigateToRecoveryPhone();
if (!handledError) {
return;
}
if (
handledError.errno === AuthUiErrors.BACKEND_SERVICE_FAILURE.errno ||
handledError.errno === AuthUiErrors.SMS_SEND_RATE_LIMIT_EXCEEDED.errno ||
handledError.errno === AuthUiErrors.UNEXPECTED_ERROR.errno
) {
setBannerErrorMessage(
ftlMsgResolver.getMsg(
'signin-recovery-code-use-phone-failure',
'There was a problem sending a code to your recovery phone'
)
);
setBannerErrorDescription(
ftlMsgResolver.getMsg(
'signin-recovery-code-use-phone-failure-description',
'Please try again later.'
)
);
return;
}
setBannerErrorMessage(
getLocalizedErrorMessage(ftlMsgResolver, handledError)
);
};
return (
<AppLayout>
<FtlMsg id="signin-recovery-code-heading">
<HeadingPrimary>Sign in</HeadingPrimary>
</FtlMsg>
<div className="relative flex items-center mb-5">
<ButtonBack />
<FtlMsg id="signin-recovery-code-heading">
<HeadingPrimary marginClass="">Sign in</HeadingPrimary>
</FtlMsg>
</div>
{bannerErrorMessage && (
<Banner
type="error"
content={{ localizedHeading: bannerErrorMessage }}
content={{
localizedHeading: bannerErrorMessage,
localizedDescription: bannerErrorDescription,
}}
/>
)}
<BackupCodesImage />
@ -166,10 +212,10 @@ const SigninRecoveryCode = ({
<h2 className="card-header">Enter backup authentication code</h2>
</FtlMsg>
<FtlMsg id="signin-recovery-code-instruction-v2">
<FtlMsg id="signin-recovery-code-instruction-v3">
<p className="mt-2 text-sm">
Enter one of the one-time use backup authentication codes you saved
during two-step authentication setup.
Enter one of the one-time-use codes you saved when you set up two-step
authentication.
</p>
</FtlMsg>
@ -191,19 +237,31 @@ const SigninRecoveryCode = ({
codeErrorMessage,
setCodeErrorMessage,
}}
gleanDataAttrs={{ id: 'login_backup_codes_submit' }}
/>
<div className="mt-10 link-blue text-sm flex justify-between">
<FtlMsg id="signin-recovery-code-back-link">
<Link
to={`/signin_totp_code${location.search || ''}`}
state={signinState}
>
Back
</Link>
</FtlMsg>
<div
className={classNames(
'mt-10 link-blue text-sm flex',
lastFourPhoneDigits ? 'justify-between' : 'justify-center'
)}
>
{lastFourPhoneDigits && (
<FtlMsg id="signin-recovery-code-phone-link">
<button
className="link-blue"
data-glean-id="login_backup_codes_phone_instead"
onClick={handleNavigateToRecoveryPhone}
>
Use recovery phone
</button>
</FtlMsg>
)}
<FtlMsg id="signin-recovery-code-support-link">
<LinkExternal href="https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication">
<LinkExternal
href="https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication"
gleanDataAttrs={{ id: 'login_backup_codes_locked_out_link' }}
>
Are you locked out?
</LinkExternal>
</FtlMsg>

Просмотреть файл

@ -2,18 +2,18 @@
* 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 { BeginSigninError } from '../../../lib/error-utils';
import { BeginSigninError, HandledError } 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;
navigateToRecoveryPhone: () => Promise<HandledError | void>;
signinState: SigninLocationState;
submitRecoveryCode: SubmitRecoveryCode;
lastFourPhoneDigits?: string;
} & SensitiveData.AuthData;
export type SubmitRecoveryCode = (

Просмотреть файл

@ -1,16 +0,0 @@
/* 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 from 'react';
import { Meta } from '@storybook/react';
import SigninRecoveryMethod from '.';
import { withLocalization } from 'fxa-react/lib/storybooks';
export default {
title: 'Pages/Signin/SigninRecoveryMethod',
component: SigninRecoveryMethod,
decorators: [withLocalization],
} as Meta;
export const Default = () => <SigninRecoveryMethod />;

Просмотреть файл

@ -1,42 +0,0 @@
/* 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 from 'react';
import FormChoice from '.';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { screen, within } from '@testing-library/react';
import SigninRecoveryMethod from '.';
function render() {
renderWithLocalizationProvider(<SigninRecoveryMethod />);
}
describe('SigninRecoveryMethod', () => {
it('renders as expected', () => {
render();
expect(
screen.getByRole('heading', { name: 'Sign in', level: 1 })
).toBeInTheDocument();
expect(
screen.getByRole('heading', {
name: 'Choose a recovery method',
level: 2,
})
).toBeInTheDocument();
expect(
within(
screen.getByRole('group').querySelector('legend') as HTMLLegendElement
).getByText('Choose a recovery method')
).toBeInTheDocument();
expect(
screen.getByText('Lets make sure its 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();
});
});

Просмотреть файл

@ -1,88 +0,0 @@
/* 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 from 'react';
import { FtlMsg } from 'fxa-react/lib/utils';
import AppLayout from '../../../components/AppLayout';
import { HeadingPrimary } from '../../../components/HeadingPrimary';
import FormChoice, {
CHOICES,
FormChoiceData,
FormChoiceOption,
} from '../../../components/FormChoice';
import { useFtlMsgResolver } from '../../../models';
import {
BackupAuthenticationCodesImage,
BackupRecoveryPhoneSmsImage,
} from '../../../components/images';
import ButtonBack from '../../../components/ButtonBack';
const SigninRecoveryMethod = () => {
const onSubmit = async ({ choice }: FormChoiceData) => {
// TODO: actually do something with this, maybe pull into container
console.log('Submitted with choice:', choice);
};
const ftlMsgResolver = useFtlMsgResolver();
// TODO, actually pull these values
const numberOfCodes = 4;
const lastFourPhoneNumber = 3019;
const formChoices: FormChoiceOption[] = [
{
id: 'recovery-choice-phone',
value: CHOICES.phone,
image: <BackupRecoveryPhoneSmsImage />,
localizedChoiceTitle: ftlMsgResolver.getMsg(
'signin-recovery-method-phone',
'Recovery phone'
),
// This doesn't need localization
localizedChoiceInfo: `••••••${lastFourPhoneNumber}`,
},
{
id: 'recovery-choice-code',
value: CHOICES.code,
image: <BackupAuthenticationCodesImage />,
localizedChoiceTitle: ftlMsgResolver.getMsg(
'signin-recovery-method-code',
'Authentication codes'
),
localizedChoiceInfo: ftlMsgResolver.getMsg(
'signin-recovery-method-code-info',
`${numberOfCodes} codes remaining`,
{ numberOfCodes }
),
},
];
return (
<AppLayout>
<div className="relative flex items-start">
<ButtonBack />
<FtlMsg id="signin-recovery-method-header">
<HeadingPrimary>Sign in</HeadingPrimary>
</FtlMsg>
</div>
<FormChoice {...{ legendEl, onSubmit, formChoices }} />
</AppLayout>
);
};
const legendEl = (
<>
<legend>
<FtlMsg id="signin-recovery-method-subheader">
<h2 className="card-header">Choose a recovery method</h2>
</FtlMsg>
</legend>
<FtlMsg id="signin-recovery-method-details">
<p className="pt-2 mb-8">
Lets make sure its you using your recovery methods.
</p>
</FtlMsg>
</>
);
export default SigninRecoveryMethod;

Просмотреть файл

@ -0,0 +1,179 @@
/* 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 ReachRouterModule from '@reach/router';
import * as SigninRecoveryPhoneModule from './index';
import { AppContext, Integration } from '../../../models';
import { mockAppContext } from '../../../models/mocks';
import SigninRecoveryPhoneContainer from './container';
import {
createMockSigninWebIntegration,
MOCK_OAUTH_FLOW_HANDLER_RESPONSE,
mockSigninLocationState,
} from '../mocks';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { SigninRecoveryPhoneProps } from './interfaces';
import { storeAccountData } from '../../../lib/storage-utils';
import { handleNavigation } from '../utils';
const mockRecoveryPhoneSigninConfirm = jest.fn().mockImplementation(() => {
return Promise.resolve();
});
const mockRecoveryPhoneSigninSendCode = jest.fn().mockImplementation(() => {
return Promise.resolve();
});
jest.mock('../../../models', () => ({
...jest.requireActual('../../../models'),
useAuthClient: () => {
return {
recoveryPhoneSigninConfirm: mockRecoveryPhoneSigninConfirm,
recoveryPhoneSigninSendCode: mockRecoveryPhoneSigninSendCode,
};
},
}));
const mockFinishOAuthFlowHandler = jest
.fn()
.mockReturnValueOnce(MOCK_OAUTH_FLOW_HANDLER_RESPONSE);
jest.mock('../../../lib/hooks', () => ({
useFinishOAuthFlowHandler: jest.fn(() => ({
finishOAuthFlowHandler: mockFinishOAuthFlowHandler,
oAuthDataError: null,
})),
useNavigateWithQuery: jest.fn(),
useWebRedirect: jest.fn(),
}));
jest.mock('../../../lib/error-utils', () => ({
getHandledError: jest.fn().mockImplementation((err) => ({
error: {
errno: err?.errno || 1,
message: 'error',
},
})),
getLocalizedErrorMessage: jest.fn().mockImplementation((err) => err.message),
}));
jest.mock('../../../lib/storage-utils', () => ({
storeAccountData: jest.fn(),
}));
jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
handleNavigation: jest.fn(),
}));
let mockNavigate = jest.fn();
function mockReachRouter(pathname = '', mockLocationState = {}) {
jest.spyOn(ReachRouterModule, 'useNavigate').mockReturnValue(mockNavigate);
jest.spyOn(ReachRouterModule, 'useLocation').mockImplementation(() => ({
...global.window.location,
pathname,
state: mockLocationState,
}));
}
let currentPageProps: SigninRecoveryPhoneProps | undefined;
function mockSigninRecoveryPhoneModule() {
jest
.spyOn(SigninRecoveryPhoneModule, 'default')
.mockImplementation((props) => {
currentPageProps = props;
return <div>signin recovery phone mock</div>;
});
}
function applyDefaultMocks() {
jest.resetAllMocks();
jest.restoreAllMocks();
mockSigninRecoveryPhoneModule();
mockReachRouter('/signin_recovery_phone', {
signinState: mockSigninLocationState,
lastFourPhoneDigits: '1234',
});
}
const renderSigninRecoveryPhoneContainer = (
integration = createMockSigninWebIntegration() as Integration
) => {
renderWithLocalizationProvider(
<ReachRouterModule.LocationProvider>
<AppContext.Provider value={mockAppContext()}>
<SigninRecoveryPhoneContainer {...{ integration }} />
</AppContext.Provider>
</ReachRouterModule.LocationProvider>
);
};
describe('SigninRecoveryPhoneContainer', () => {
beforeEach(() => {
jest.clearAllMocks();
applyDefaultMocks();
});
describe('pre-render navigation', () => {
it('navigates to /signin if signinState is missing', () => {
mockReachRouter('/signin_recovery_phone', {
lastFourPhoneDigits: '1234',
});
renderSigninRecoveryPhoneContainer();
expect(mockNavigate).toHaveBeenCalledWith('/signin');
});
it('navigates to /signin if lastFourPhoneDigits is missing', () => {
mockReachRouter('/signin_recovery_phone', {
signinState: mockSigninLocationState,
});
renderSigninRecoveryPhoneContainer();
expect(mockNavigate).toHaveBeenCalledWith('/signin');
});
});
describe('with web integration', () => {
it('renders SigninRecoveryPhone component with proper props', async () => {
renderSigninRecoveryPhoneContainer();
expect(SigninRecoveryPhoneModule.default).toHaveBeenCalled();
expect(currentPageProps).toEqual({
lastFourPhoneDigits: '1234',
resendCode: expect.any(Function),
verifyCode: expect.any(Function),
});
});
it('calls verifyCode correctly', async () => {
renderSigninRecoveryPhoneContainer();
await currentPageProps?.verifyCode('123456');
expect(mockRecoveryPhoneSigninConfirm).toHaveBeenCalledWith(
mockSigninLocationState.sessionToken,
'123456'
);
expect(storeAccountData).toHaveBeenCalledWith({
email: mockSigninLocationState.email,
sessionToken: mockSigninLocationState.sessionToken,
uid: mockSigninLocationState.uid,
verified: true,
});
expect(handleNavigation).toHaveBeenCalled();
});
it('calls resendCode correctly', async () => {
renderSigninRecoveryPhoneContainer();
await currentPageProps?.resendCode();
expect(mockRecoveryPhoneSigninSendCode).toHaveBeenCalledWith(
mockSigninLocationState.sessionToken
);
});
});
});

Просмотреть файл

@ -0,0 +1,169 @@
/* 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, { useEffect } from 'react';
import { RouteComponentProps, useLocation } from '@reach/router';
import SigninRecoveryPhone from '.';
import { SigninLocationState } from '../interfaces';
import { getSigninState, handleNavigation } from '../utils';
import {
isWebIntegration,
useAuthClient,
useSensitiveDataClient,
} from '../../../models';
import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery';
import { getHandledError } 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 { storeAccountData } from '../../../lib/storage-utils';
import { useWebRedirect } from '../../../lib/hooks/useWebRedirect';
import {
SigninRecoveryPhoneContainerProps,
SigninRecoveryPhoneLocationState,
} from './interfaces';
const SigninRecoveryPhoneContainer = ({
integration,
}: SigninRecoveryPhoneContainerProps & RouteComponentProps) => {
const authClient = useAuthClient();
const location = useLocation() as ReturnType<typeof useLocation> & {
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
);
const webRedirectCheck = useWebRedirect(integration.data.redirectTo);
const redirectTo =
isWebIntegration(integration) && webRedirectCheck?.isValid
? integration.data.redirectTo
: '';
const handleSuccess = async () => {
if (!signinState) {
return;
}
try {
storeAccountData({
sessionToken: signinState.sessionToken,
email: signinState.email,
uid: signinState.uid,
// Update verification status of stored current account
verified: true,
});
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,
};
await handleNavigation(navigationOptions);
} catch (error) {
throw error;
}
};
const verifyCode = async (otpCode: string) => {
if (!signinState) {
return;
}
try {
await authClient.recoveryPhoneSigninConfirm(
signinState.sessionToken,
otpCode
);
await handleSuccess();
return;
} catch (err) {
const { error: handledError } = getHandledError(err);
if (handledError.errno === AuthUiErrors.INVALID_TOKEN.errno) {
navigateWithQuery('/signin', { replace: true });
return;
}
return handledError;
}
};
const resendCode = async () => {
if (!signinState) {
return;
}
try {
await authClient.recoveryPhoneSigninSendCode(signinState.sessionToken);
return;
} catch (err) {
const { error: handledError } = getHandledError(err);
if (handledError.errno === AuthUiErrors.INVALID_TOKEN.errno) {
navigateWithQuery('/signin', { replace: true });
return;
}
return handledError;
}
};
if (oAuthDataError) {
return <OAuthDataError error={oAuthDataError} />;
}
if (oAuthKeysCheckError) {
return <OAuthDataError error={oAuthKeysCheckError} />;
}
return (
<SigninRecoveryPhone
{...{
lastFourPhoneDigits,
verifyCode,
resendCode,
}}
/>
);
};
export default SigninRecoveryPhoneContainer;

Просмотреть файл

@ -0,0 +1,27 @@
## 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 <span>{ $maskedPhoneNumber }</span> 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?
signin-recovery-phone-send-code-error-heading = There was a problem sending a code
signin-recovery-phone-code-verification-error-heading = There was a problem verifying your code
# Follows the error message (e.g, "There was a problem sending a code")
signin-recovery-phone-general-error-description = Please try again later.

Просмотреть файл

@ -0,0 +1,56 @@
/* 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 from 'react';
import { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { Subject } from './mocks';
import SigninRecoveryPhone from '.';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { HandledError } from '../../../lib/error-utils';
export default {
title: 'Pages/Signin/SigninRecoveryPhone',
component: SigninRecoveryPhone,
decorators: [withLocalization],
} as Meta;
export const Basic = () => (
<Subject
verifyCode={(code: string) => {
action('verifyCode')(code);
return Promise.resolve();
}}
/>
);
export const WithCodeErrorOnSubmit = () => (
<Subject
verifyCode={(code: string) =>
Promise.resolve(AuthUiErrors.INVALID_EXPIRED_OTP_CODE as HandledError)
}
resendCode={() => Promise.resolve()}
/>
);
export const WithGeneralErrorMessages = () => (
<Subject
verifyCode={(code: string) =>
Promise.resolve(AuthUiErrors.BACKEND_SERVICE_FAILURE as HandledError)
}
resendCode={() =>
Promise.resolve(AuthUiErrors.BACKEND_SERVICE_FAILURE as HandledError)
}
/>
);
export const WithThrottlingErrorMessages = () => (
<Subject
verifyCode={(code: string) =>
Promise.resolve(AuthUiErrors.THROTTLED as HandledError)
}
resendCode={() => Promise.resolve(AuthUiErrors.THROTTLED as HandledError)}
/>
);

Просмотреть файл

@ -0,0 +1,103 @@
/* 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 from 'react';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import SigninRecoveryPhone from './index';
describe('SigninRecoveryPhone', () => {
const mockVerifyCode = jest.fn(() => Promise.resolve());
const mockResendCode = jest.fn(() => Promise.resolve());
const defaultProps = {
lastFourPhoneDigits: '1234',
verifyCode: mockVerifyCode,
resendCode: mockResendCode,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders as expected', async () => {
renderWithLocalizationProvider(<SigninRecoveryPhone {...defaultProps} />);
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Sign in' })
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Enter recovery code' })
).toBeInTheDocument();
expect(
screen.getByRole('textbox', { name: 'Enter 6-digit code' })
).toBeInTheDocument();
expect(screen.getByText('Confirm')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Resend code' })
).toBeInTheDocument();
expect(
screen.getByRole('link', {
name: 'Are you locked out? Opens in new window',
})
).toBeInTheDocument();
});
it('has expected glean click events', async () => {
const user = userEvent.setup();
renderWithLocalizationProvider(<SigninRecoveryPhone {...defaultProps} />);
await waitFor(() => user.type(screen.getByRole('textbox'), '123456'));
expect(screen.getByRole('button', { name: /Confirm/i })).toHaveAttribute(
'data-glean-id',
'login_backup_phone_submit'
);
const resendButton = screen.getByRole('button', { name: /Resend code/i });
expect(resendButton).toHaveAttribute(
'data-glean-id',
'login_backup_phone_resend'
);
const lockedOutLink = screen.getByRole('link', {
name: /Are you locked out\?/i,
});
expect(lockedOutLink).toHaveAttribute(
'data-glean-id',
'login_backup_phone_locked_out_link'
);
});
it('submits with valid code', async () => {
renderWithLocalizationProvider(<SigninRecoveryPhone {...defaultProps} />);
const input = screen.getByRole('textbox');
await waitFor(() => userEvent.type(input, '123456'));
userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => {
expect(mockVerifyCode).toHaveBeenCalledWith('123456');
});
});
it('handles resend code', async () => {
renderWithLocalizationProvider(<SigninRecoveryPhone {...defaultProps} />);
userEvent.click(screen.getByRole('button', { name: 'Resend code' }));
await waitFor(() => expect(mockResendCode).toHaveBeenCalled());
});
it('handles `Are you locked out?` link', async () => {
renderWithLocalizationProvider(<SigninRecoveryPhone {...defaultProps} />);
const link = screen.getByRole('link', { name: /Are you locked out/i });
expect(link).toHaveAttribute(
'href',
'https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication'
);
});
});

Просмотреть файл

@ -0,0 +1,179 @@
/* 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 from 'react';
import AppLayout from '../../../components/AppLayout';
import FormVerifyTotp from '../../../components/FormVerifyTotp';
import { RouteComponentProps } from '@reach/router';
import { useFtlMsgResolver } from '../../../models';
import { FtlMsg } from 'fxa-react/lib/utils';
import { BackupRecoveryPhoneCodeImage } from '../../../components/images';
import Banner from '../../../components/Banner';
import { HeadingPrimary } from '../../../components/HeadingPrimary';
import LinkExternal from 'fxa-react/components/LinkExternal';
import ButtonBack from '../../../components/ButtonBack';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { SigninRecoveryPhoneProps } from './interfaces';
const SigninRecoveryPhone = ({
lastFourPhoneDigits,
verifyCode,
resendCode,
}: SigninRecoveryPhoneProps & RouteComponentProps) => {
const [errorMessage, setErrorMessage] = React.useState('');
const [errorDescription, setErrorDescription] = React.useState('');
const [showResendSuccessBanner, setShowResendSuccessBanner] =
React.useState(false);
const ftlMsgResolver = useFtlMsgResolver();
const maskedPhoneNumber = `••••••${lastFourPhoneDigits}`;
const spanElement = <span className="font-bold">{maskedPhoneNumber}</span>;
const clearBanners = () => {
setErrorMessage('');
setErrorDescription('');
setShowResendSuccessBanner(false);
};
const localizedGeneralSendCodeErrorHeading = ftlMsgResolver.getMsg(
'signin-recovery-phone-send-code-error-heading',
'There was a problem sending a code'
);
const localizedGeneralCodeVerificationErrorHeading = ftlMsgResolver.getMsg(
'signin-recovery-phone-code-verification-error-heading',
'There was a problem verifying your code'
);
const localizedGeneralErrorDescription = ftlMsgResolver.getMsg(
'signin-recovery-phone-general-error-description',
'Please try again later.'
);
const handleVerifyCode = async (code: string) => {
clearBanners();
const error = await verifyCode(code);
if (error) {
if (
error === AuthUiErrors.BACKEND_SERVICE_FAILURE ||
error === AuthUiErrors.UNEXPECTED_ERROR
) {
setErrorMessage(localizedGeneralCodeVerificationErrorHeading);
setErrorDescription(localizedGeneralErrorDescription);
return;
}
setErrorMessage(getLocalizedErrorMessage(ftlMsgResolver, error));
return;
}
};
const handleResendCode = async () => {
clearBanners();
const error = await resendCode();
if (error) {
if (
error === AuthUiErrors.BACKEND_SERVICE_FAILURE ||
error === AuthUiErrors.SMS_SEND_RATE_LIMIT_EXCEEDED ||
error === AuthUiErrors.UNEXPECTED_ERROR
) {
setErrorMessage(localizedGeneralSendCodeErrorHeading);
setErrorDescription(localizedGeneralErrorDescription);
return;
}
setErrorMessage(getLocalizedErrorMessage(ftlMsgResolver, error));
return;
}
setShowResendSuccessBanner(true);
};
return (
<AppLayout>
<div className="relative flex items-center">
<ButtonBack />
<FtlMsg id="signin-recovery-phone-flow-heading">
<HeadingPrimary marginClass="">Sign in</HeadingPrimary>
</FtlMsg>
</div>
{errorMessage && (
<Banner
type="error"
content={{
localizedHeading: errorMessage,
localizedDescription: errorDescription,
}}
/>
)}
{showResendSuccessBanner && (
<Banner
type="success"
content={{
localizedHeading: ftlMsgResolver.getMsg(
'signin-recovery-phone-resend-success',
'Code sent'
),
}}
/>
)}
<BackupRecoveryPhoneCodeImage />
<FtlMsg id="signin-recovery-phone-heading">
<h2 className="card-header my-4">Enter recovery code</h2>
</FtlMsg>
<FtlMsg
id="signin-recovery-phone-instruction"
vars={{ maskedPhoneNumber }}
elems={{ span: spanElement }}
>
<p>
A six-digit code was sent to {spanElement} by text message. This code
expires after 5 minutes.
</p>
</FtlMsg>
<FormVerifyTotp
codeLength={6}
codeType="numeric"
localizedInputLabel={ftlMsgResolver.getMsg(
'signin-recovery-phone-input-label',
'Enter 6-digit code'
)}
localizedSubmitButtonText={ftlMsgResolver.getMsg(
'signin-recovery-phone-code-submit-button',
'Confirm'
)}
verifyCode={handleVerifyCode}
{...{
clearBanners,
errorMessage,
setErrorMessage,
}}
gleanDataAttrs={{ id: 'login_backup_phone_submit' }}
/>
<div className="flex justify-between mt-5 text-sm">
<FtlMsg id="signin-recovery-phone-resend-code-button">
<button
className="link-blue mt-4 text-sm"
data-glean-id="login_backup_phone_resend"
onClick={handleResendCode}
>
Resend code
</button>
</FtlMsg>
<FtlMsg id="signin-recovery-phone-locked-out-link">
<LinkExternal
href="https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication"
className="link-blue mt-4 text-sm"
gleanDataAttrs={{ id: 'login_backup_phone_locked_out_link' }}
>
Are you locked out?
</LinkExternal>
</FtlMsg>
</div>
</AppLayout>
);
};
export default SigninRecoveryPhone;

Просмотреть файл

@ -0,0 +1,22 @@
/* 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 { HandledError } from '../../../lib/error-utils';
import { Integration } from '../../../models';
import { SigninLocationState } from '../interfaces';
export interface SigninRecoveryPhoneContainerProps {
integration: Integration;
}
export interface SigninRecoveryPhoneLocationState extends SigninLocationState {
signinState: SigninLocationState;
lastFourPhoneDigits: string;
}
export type SigninRecoveryPhoneProps = {
lastFourPhoneDigits: string;
verifyCode: (code: string) => Promise<HandledError | void>;
resendCode: () => Promise<HandledError | void>;
};

Просмотреть файл

@ -0,0 +1,30 @@
/* 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 from 'react';
import { LocationProvider } from '@reach/router';
import SigninRecoveryPhone from '.';
import { SigninRecoveryPhoneProps } from './interfaces';
const mockVerifyCodeSuccess = (code: string) => Promise.resolve();
const mockResendCodeSuccess = () => Promise.resolve();
export const Subject = ({
verifyCode = mockVerifyCodeSuccess,
resendCode = mockResendCodeSuccess,
}: Partial<SigninRecoveryPhoneProps>) => {
const lastFourPhoneDigits = '1234';
return (
<LocationProvider>
<SigninRecoveryPhone
{...{
lastFourPhoneDigits,
verifyCode,
resendCode,
}}
/>
</LocationProvider>
);
};

Просмотреть файл

@ -1,44 +0,0 @@
/* 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, { useState } from 'react';
import { RouteComponentProps } from '@reach/router';
import ConfirmRecoveryCode from '.';
import { ResendStatus } from '../../../lib/types';
const ConfirmRecoveryCodeContainer = (_: RouteComponentProps) => {
const [errorMessage, setErrorMessage] = useState('');
const [resendErrorMessage, setResendErrorMessage] = useState('');
const [resendStatus, setResendStatus] = useState<ResendStatus>(
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 (
<ConfirmRecoveryCode
{...{
clearBanners,
maskedPhoneNumber,
errorMessage,
resendErrorMessage,
setErrorMessage,
verifyCode,
resendCode,
}}
/>
);
};
export default ConfirmRecoveryCodeContainer;

Просмотреть файл

@ -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 <span>{ $maskedPhoneNumber }</span> 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

Просмотреть файл

@ -1,28 +0,0 @@
/* 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 from 'react';
import { Meta } from '@storybook/react';
import { withLocalization } from 'fxa-react/lib/storybooks';
import ConfirmRecoveryCode from '.';
import { Subject } from './mocks';
export default {
title: 'Pages/Signin/SigninRecoveryMethod/Phone/ConfirmRecoveryCode',
component: ConfirmRecoveryCode,
decorators: [withLocalization],
} as Meta;
export const Basic = () => <Subject />;
export const WithErrorMessage = () => (
<ConfirmRecoveryCode
errorMessage="An error occurred. Please try again."
maskedPhoneNumber="••••••1234"
verifyCode={() => Promise.resolve()}
resendCode={() => Promise.resolve()}
clearBanners={() => {}}
setErrorMessage={() => {}}
/>
);

Просмотреть файл

@ -1,93 +0,0 @@
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';
describe('SigninRecoveryPhoneCodeConfirm', () => {
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,
verifyCode: mockVerifyCode,
resendCode: mockResendCode,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders as expected', async () => {
renderWithLocalizationProvider(
<SigninRecoveryPhoneCodeConfirm {...defaultProps} />
);
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Sign in' })
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Enter recovery code' })
).toBeInTheDocument();
expect(
screen.getByRole('textbox', { name: 'Enter 6-digit code' })
).toBeInTheDocument();
expect(screen.getByText('Confirm')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Resend code' })
).toBeInTheDocument();
expect(
screen.getByRole('link', {
name: 'Are you locked out? Opens in new window',
})
).toBeInTheDocument();
});
it('submits with valid code', async () => {
renderWithLocalizationProvider(
<SigninRecoveryPhoneCodeConfirm {...defaultProps} />
);
const input = screen.getByRole('textbox');
await act(async () => {
await userEvent.type(input, '123456');
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
});
expect(mockVerifyCode).toHaveBeenCalledWith('123456');
});
it('handles resend code', async () => {
renderWithLocalizationProvider(
<SigninRecoveryPhoneCodeConfirm {...defaultProps} />
);
await act(async () => {
await userEvent.click(
screen.getByRole('button', { name: 'Resend code' })
);
});
expect(mockResendCode).toHaveBeenCalled();
});
it('handles `Are you locked out?` link', async () => {
renderWithLocalizationProvider(
<SigninRecoveryPhoneCodeConfirm {...defaultProps} />
);
const link = screen.getByRole('link', {
name: 'Are you locked out? Opens in new window',
});
expect(link).toHaveAttribute(
'href',
'https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication'
);
});
});

Просмотреть файл

@ -1,106 +0,0 @@
/* 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 from 'react';
import AppLayout from '../../../components/AppLayout';
import FormVerifyTotp from '../../../components/FormVerifyTotp';
import { RouteComponentProps } from '@reach/router';
import { useFtlMsgResolver } from '../../../models';
import { FtlMsg } from 'fxa-react/lib/utils';
import { BackupRecoveryPhoneCodeImage } from '../../../components/images';
import Banner from '../../../components/Banner';
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<React.SetStateAction<string>>;
verifyCode: (code: string) => Promise<void>;
resendCode: () => Promise<void>;
};
const SigninRecoveryPhoneCodeConfirm = ({
clearBanners,
maskedPhoneNumber,
errorMessage,
setErrorMessage,
verifyCode,
resendCode,
}: SigninRecoveryPhoneCodeConfirmProps & RouteComponentProps) => {
const ftlMsgResolver = useFtlMsgResolver();
const spanElement = <span className="font-bold">{maskedPhoneNumber}</span>;
return (
<AppLayout>
<div className="relative flex items-start">
<ButtonBack />
<FtlMsg id="signin-recovery-method-header">
<HeadingPrimary>Sign in</HeadingPrimary>
</FtlMsg>
</div>
{errorMessage && (
<Banner type="error" content={{ localizedHeading: errorMessage }} />
)}
<BackupRecoveryPhoneCodeImage />
<FtlMsg id="confirm-recovery-code-with-code-heading">
<h2 className="card-header my-4">Enter recovery code</h2>
</FtlMsg>
<FtlMsg
id="confirm-recovery-code-with-code-instruction"
vars={{ maskedPhoneNumber }}
elems={{ span: spanElement }}
>
<p>
A six-digit code was sent to {spanElement} by text message. This code
expires after 5 minutes.
</p>
</FtlMsg>
<FormVerifyTotp
codeLength={6}
codeType="numeric"
localizedInputLabel={ftlMsgResolver.getMsg(
'confirm-recovery-code-code-input-group-label',
'Enter 6-digit code'
)}
localizedSubmitButtonText={ftlMsgResolver.getMsg(
'confirm-recovery-code-otp-submit-button',
'Confirm'
)}
{...{
clearBanners,
errorMessage,
setErrorMessage,
verifyCode,
}}
/>
<div className="flex justify-between mt-5 text-sm">
<FtlMsg id="confirm-recovery-code-otp-resend-code-button">
<button
className="link-blue mt-4 text-sm"
data-glean-id="login_backup_phone_codes_resend"
onClick={resendCode}
>
Resend code
</button>
</FtlMsg>
<FtlMsg id="confirm-recovery-code-otp-different-account-link">
<LinkExternal
href="https://support.mozilla.org/kb/what-if-im-locked-out-two-step-authentication"
className="link-blue mt-4 text-sm"
data-glean-id="login_backup_phone_locked_out_link"
>
Are you locked out?
</LinkExternal>
</FtlMsg>
</div>
</AppLayout>
);
};
export default SigninRecoveryPhoneCodeConfirm;

Просмотреть файл

@ -1,38 +0,0 @@
/* 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, { useState } from 'react';
import ConfirmRecoveryCode from '.';
import { MOCK_MASKED_PHONE_NUMBER } from '../../mocks';
import { LocationProvider } from '@reach/router';
import { SigninRecoveryPhoneCodeConfirmProps } from '.';
const mockVerifyCode = (code: string) => Promise.resolve();
const mockResendCode = () => Promise.resolve();
export const Subject = ({
verifyCode = mockVerifyCode,
resendCode = mockResendCode,
}: Partial<SigninRecoveryPhoneCodeConfirmProps>) => {
const [errorMessage, setErrorMessage] = useState('');
const clearBanners = () => {
setErrorMessage('');
};
return (
<LocationProvider>
<ConfirmRecoveryCode
maskedPhoneNumber={MOCK_MASKED_PHONE_NUMBER}
{...{
clearBanners,
errorMessage,
setErrorMessage,
verifyCode,
resendCode,
}}
/>
</LocationProvider>
);
};

Просмотреть файл

@ -218,7 +218,7 @@ describe('SigninTokenCode page', () => {
session = {
verifySession: jest
.fn()
.mockRejectedValue(AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE),
.mockRejectedValue(AuthUiErrors.INVALID_EXPIRED_OTP_CODE),
} as unknown as Session;
render();
submitCode();

Просмотреть файл

@ -38,7 +38,6 @@ import {
mockLoadingSpinnerModule,
} from '../../mocks';
import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade';
import { SensitiveData } from '../../../lib/sensitive-data-client';
let integration: Integration;

Просмотреть файл

@ -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 (
<AppLayout>
<FtlMsg id="signin-totp-code-header">
@ -190,7 +195,7 @@ export const SigninTotpCode = ({
</FtlMsg>
<FtlMsg id="signin-totp-code-recovery-code-link">
<Link
to={`/signin_recovery_code${location.search}`}
to={`/${troubleWithCodeTarget}${location.search}`}
state={signinState}
className="text-end"
data-glean-id="login_totp_code_trouble_link"

Просмотреть файл

@ -45,7 +45,6 @@ import {
} from './mocks';
import { BeginSigninResult, SigninUnblockIntegration } from '../interfaces';
import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade';
import { SensitiveData } from '../../../lib/sensitive-data-client';
let integration: SigninUnblockIntegration;
function mockWebIntegration() {

Просмотреть файл

@ -23,7 +23,7 @@ const accountWithSuccess = {
} as unknown as Account;
const accountWithErrors = {
verifySession: () => Promise.reject(AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE),
verifySession: () => Promise.reject(AuthUiErrors.INVALID_EXPIRED_OTP_CODE),
sendVerificationCode: () => Promise.reject(AuthUiErrors.UNEXPECTED_ERROR),
} as unknown as Account;

Просмотреть файл

@ -237,13 +237,13 @@ const ConfirmSignupCode = ({
}
} catch (error) {
let localizedErrorMessage: string;
// Intercept invalid parameter error and set the error message to INVALID_EXPIRED_SIGNUP_CODE
// Intercept invalid parameter error and set the error message to INVALID_EXPIRED_OTP_CODE
// This error occurs when the submitted code does not pass validation for the code param
// e.g., if the submitted code contains spaces or characters other than numbers
if (error.errno === 107) {
localizedErrorMessage = ftlMsgResolver.getMsg(
getErrorFtlId(AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE),
AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE.message
getErrorFtlId(AuthUiErrors.INVALID_EXPIRED_OTP_CODE),
AuthUiErrors.INVALID_EXPIRED_OTP_CODE.message
);
} else {
localizedErrorMessage = getLocalizedErrorMessage(ftlMsgResolver, error);
@ -251,7 +251,7 @@ const ConfirmSignupCode = ({
// In any case where the submitted code is invalid/expired, show the error message in a tooltip
if (
error.errno === AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE.errno ||
error.errno === AuthUiErrors.INVALID_EXPIRED_OTP_CODE.errno ||
error.errno === AuthUiErrors.OTP_CODE_REQUIRED.errno ||
error.errno === AuthUiErrors.INVALID_OTP_CODE.errno ||
error.errno === 107