зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
30d65aabf5
Коммит
9b0fbc11fd
|
@ -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 = Let’s make sure it’s you using your recovery methods.
|
||||
signin-recovery-method-phone = Recovery phone
|
||||
signin-recovery-method-code = Authentication codes
|
||||
signin-recovery-method-code-v2 = Backup authentication codes
|
||||
# Variable: $numberOfCodes (String) - The number of authentication codes the user has left, e.g. 4
|
||||
signin-recovery-method-code-info = { $numberOfCodes } codes remaining
|
||||
# Shown when a backend service fails and a code cannot be sent to the user's recovery phone.
|
||||
signin-recovery-phone-send-code-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('Let’s make sure it’s 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">
|
||||
Let’s make sure it’s 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('Let’s make sure it’s you using your recovery methods.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Recovery phone/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/4 codes remaining/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Authentication codes/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/••••••3019/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -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">
|
||||
Let’s make sure it’s 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
|
||||
|
|
Загрузка…
Ссылка в новой задаче