Merge pull request #16395 from mozilla/FXA-6490

feat(react): Recreate '/signin_token_code' page functionality in React
This commit is contained in:
Lauren Zugai 2024-02-20 10:09:45 -06:00 коммит произвёл GitHub
Родитель ae6a7a5f86 2719e4a2ef
Коммит 33f96a842c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
26 изменённых файлов: 833 добавлений и 150 удалений

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

@ -64,6 +64,7 @@ const getReactRouteGroups = (showReactApp, reactRoute) => {
featureFlagOn: showReactApp.signInRoutes,
routes: reactRoute.getRoutes([
'signin',
'signin_token_code',
'signin_reported',
'signin_confirmed',
'signin_verified',

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

@ -80,3 +80,17 @@ export const GET_PRODUCT_INFO = gql`
}
}
`;
/**
* This query fetches the current account TOTP (2FA Auth) status.
*/
export const GET_TOTP_STATUS = gql`
query GetTotpStatus {
account {
totp {
exists
verified
}
}
}
`;

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

@ -3,7 +3,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { RouteComponentProps, Router, useLocation } from '@reach/router';
import {
RouteComponentProps,
Router,
useLocation,
useNavigate,
} from '@reach/router';
import { QueryParams } from '../..';
@ -63,6 +68,7 @@ import SignupContainer from '../../pages/Signup/container';
import ThirdPartyAuthCallback from '../../pages/PostVerify/ThirdPartyAuthCallback';
import WebChannelExample from '../../pages/WebChannelExample';
import SigninTotpCodeContainer from '../../pages/Signin/SigninTotpCode/container';
import SigninTokenCodeContainer from '../../pages/Signin/SigninTokenCode/container';
const Settings = lazy(() => import('../Settings'));
@ -176,6 +182,7 @@ const SettingsRoutes = ({
isSignedIn,
integration,
}: { isSignedIn: boolean; integration: Integration } & RouteComponentProps) => {
const navigate = useNavigate();
const location = useLocation();
// TODO: Remove this + config.sendFxAStatusOnSettings check once we confirm this works
const config = useConfig();
@ -201,9 +208,12 @@ const SettingsRoutes = ({
});
if (!isSignedIn && !shouldCheckFxaStatus) {
hardNavigateToContentServer(
`/signin?redirect_to=${encodeURIComponent(location.pathname)}`
);
const path = `/signin?redirect_to=${encodeURIComponent(location.pathname)}`;
if (config.showReactApp.signInRoutes) {
navigate(path);
} else {
hardNavigateToContentServer(path);
}
return <LoadingSpinner fullScreen />;
}
@ -297,14 +307,18 @@ const AuthAndAccountSetupRoutes = ({
{...{ isSignedIn, serviceName }}
/>
<SigninReported path="/signin_reported/*" />
<SigninConfirmed
path="/signin_verified/*"
{...{ isSignedIn, serviceName }}
<SigninTokenCodeContainer
path="/signin_token_code/*"
{...{ integration }}
/>
<SigninTotpCodeContainer
path="/signin_totp_code/*"
{...{ serviceName }}
/>
<SigninConfirmed
path="/signin_verified/*"
{...{ isSignedIn, serviceName }}
/>
{/* Signup */}
<CannotCreateAccount path="/cannot_create_account/*" />

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

@ -6,7 +6,7 @@ import classNames from 'classnames';
import React, { ReactElement } from 'react';
import { FtlMsg } from 'fxa-react/lib/utils';
import { ReactComponent as IconClose } from 'fxa-react/images/close.svg';
import { FIREFOX_NOREPLY_EMAIL } from 'fxa-settings/src/constants';
import { FIREFOX_NOREPLY_EMAIL } from '../../constants';
export enum BannerType {
info = 'info',
@ -18,6 +18,7 @@ type DefaultProps = {
type: BannerType;
children: ReactElement | string;
additionalClassNames?: string;
animation?: Animation;
};
type OptionalProps =
@ -29,12 +30,19 @@ type OptionalProps =
export type BannerProps = DefaultProps & OptionalProps;
type Animation = {
className: string;
handleAnimationEnd: () => void;
animate: boolean;
};
const Banner = ({
type,
children,
additionalClassNames,
dismissible,
setIsVisible,
animation,
}: BannerProps) => {
// Transparent border is for Windows HCM - to ensure there is a border around the banner
const baseClassNames =
@ -48,8 +56,10 @@ const Banner = ({
type === BannerType.success && 'bg-green-500 text-grey-900',
type === BannerType.error && 'bg-red-700 text-white',
dismissible && 'flex gap-2 items-center ',
animation?.animate && animation?.className,
additionalClassNames
)}
onAnimationEnd={animation?.handleAnimationEnd}
>
{dismissible ? (
<>
@ -72,9 +82,13 @@ const Banner = ({
};
export default Banner;
export const ResendEmailSuccessBanner = () => {
export const ResendEmailSuccessBanner = ({
animation,
}: {
animation?: Animation;
}) => {
return (
<Banner type={BannerType.success}>
<Banner type={BannerType.success} {...{ animation }}>
<FtlMsg
id="link-expired-resent-link-success-message"
vars={{ accountsEmail: FIREFOX_NOREPLY_EMAIL }}
@ -85,13 +99,3 @@ export const ResendEmailSuccessBanner = () => {
</Banner>
);
};
export const ResendCodeErrorBanner = () => {
return (
<Banner type={BannerType.error}>
<FtlMsg id="link-expired-resent-code-error-message">
Something went wrong. A new code could not be sent.
</FtlMsg>
</Banner>
);
};

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

@ -22,7 +22,7 @@ export type FormAttributes = {
export type FormVerifyCodeProps = {
viewName: string;
formAttributes: FormAttributes;
verifyCode: (code: string) => void;
verifyCode: (code: string) => Promise<void>;
localizedCustomCodeRequiredMessage?: string;
codeErrorMessage: string;
setCodeErrorMessage: React.Dispatch<React.SetStateAction<string>>;
@ -43,6 +43,7 @@ const FormVerifyCode = ({
setClearMessages,
}: FormVerifyCodeProps) => {
const [isFocused, setIsFocused] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const ftlMsgResolver = useFtlMsgResolver();
const localizedLabel = ftlMsgResolver.getMsg(
@ -84,8 +85,10 @@ const FormVerifyCode = ({
localizedDefaultCodeRequiredMessage,
]);
const onSubmit = ({ code }: FormData) => {
verifyCode(code.trim());
const onSubmit = async ({ code }: FormData) => {
setIsSubmitting(true);
await verifyCode(code.trim());
setIsSubmitting(false);
};
return (
@ -120,7 +123,11 @@ const FormVerifyCode = ({
/>
<FtlMsg id={formAttributes.submitButtonFtlId}>
<button type="submit" className="cta-primary cta-xl">
<button
type="submit"
className="cta-primary cta-xl"
disabled={isSubmitting}
>
{formAttributes.submitButtonText}
</button>
</FtlMsg>

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

@ -19,7 +19,7 @@ export const Subject = ({
submitButtonText: 'Check that code',
};
const onFormSubmit = () => {
const onFormSubmit = async () => {
alert('Trying to submit');
};

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

@ -8,3 +8,14 @@ export interface AccountAvatar {
id: string | null;
url: string | null;
}
export interface AccountTotp {
exists: boolean;
verified: boolean;
}
export interface HandledError {
errno: number;
message: string;
ftlId: string;
}

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

@ -24,8 +24,11 @@ import Storage from '../lib/storage';
import random from '../lib/random';
import { AuthUiErrorNos, AuthUiErrors } from '../lib/auth-errors/auth-errors';
import { LinkedAccountProviderIds, MozServices } from '../lib/types';
import { GET_LOCAL_SIGNED_IN_STATUS } from '../components/App/gql';
import { AccountAvatar } from '../lib/interfaces';
import {
GET_LOCAL_SIGNED_IN_STATUS,
GET_TOTP_STATUS,
} from '../components/App/gql';
import { AccountAvatar, AccountTotp } from '../lib/interfaces';
import { createSaltV2 } from 'fxa-auth-client/lib/salt';
export interface DeviceLocation {
@ -94,10 +97,7 @@ export interface AccountData {
emails: Email[];
attachedClients: AttachedClient[];
linkedAccounts: LinkedAccount[];
totp: {
exists: boolean;
verified: boolean;
};
totp: AccountTotp;
subscriptions: {
created: number;
productName: string;
@ -226,17 +226,6 @@ export const GET_RECOVERY_KEY_EXISTS = gql`
}
`;
export const GET_TOTP_STATUS = gql`
query GetRecoveryKeyExists {
account {
totp {
exists
verified
}
}
}
`;
export const GET_SECURITY_EVENTS = gql`
query GetSecurityEvents {
account {

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

@ -76,6 +76,7 @@ export class Session implements SessionData {
return this.data.verified;
}
// TODO: Use GQL verifyCode instead of authClient
async verifySession(
code: string,
options: {

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

@ -54,7 +54,7 @@ export const InlineTotpSetup = ({
const [showQR, setShowQR] = useState(true);
const [totpErrorMessage, setTotpErrorMessage] = useState('');
const onSubmit = () => {
const onSubmit = async () => {
// TODO: Error message for empty field here or in FormVerifyCode?
// Holding on l10n pending product decision
// See FXA-6422, and discussion on PR-14744

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

@ -50,7 +50,7 @@ const AuthTotp = ({
submitButtonText: 'Confirm',
};
const onSubmit = () => {
const onSubmit = async () => {
try {
// Check authentication code
// logViewEvent('flow', `${viewName}.submit`, ENTRYPOINT_REACT);

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

@ -0,0 +1,207 @@
/* 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 ApolloModule from '@apollo/client';
import * as SigninTokenCodeModule from '.';
import * as ReactUtils from 'fxa-react/lib/utils';
import * as CacheModule from '../../../lib/cache';
import { SigninTokenCodeIntegration, SigninTokenCodeProps } from './interfaces';
import { IntegrationType } from '../../../models';
import VerificationReasons from '../../../constants/verification-reasons';
import { MOCK_TOTP_STATUS, MOCK_TOTP_STATUS_VERIFIED } from '../mocks';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { LocationProvider } from '@reach/router';
import SigninTokenCodeContainer from './container';
import { screen, waitFor } from '@testing-library/react';
import { MOCK_STORED_ACCOUNT } from '../../mocks';
let integration: SigninTokenCodeIntegration;
function mockWebIntegration() {
integration = {
type: IntegrationType.Web,
isSync: () => false,
};
}
function applyDefaultMocks() {
jest.resetAllMocks();
jest.restoreAllMocks();
mockReactUtilsModule();
mockWebIntegration();
mockApolloClientModule();
mockLocationState = {};
mockSigninTokenCodeModule();
mockCurrentAccount();
}
// Set this when testing location state
let mockLocationState = {};
const mockLocation = () => {
return {
pathname: '/signin_token_code',
search: '?' + new URLSearchParams(mockLocationState),
state: mockLocationState,
};
};
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => {
return {
__esModule: true,
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
useLocation: () => mockLocation(),
};
});
let currentSigninTokenCodeProps: SigninTokenCodeProps | undefined;
function mockSigninTokenCodeModule() {
currentSigninTokenCodeProps = undefined;
jest
.spyOn(SigninTokenCodeModule, 'default')
.mockImplementation((props: SigninTokenCodeProps) => {
currentSigninTokenCodeProps = props;
return <div>signin token code mock</div>;
});
}
function mockReactUtilsModule() {
jest
.spyOn(ReactUtils, 'hardNavigateToContentServer')
.mockImplementation(() => {});
}
// Set this when testing local storage
function mockCurrentAccount(storedAccount = { uid: '123' }) {
jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(storedAccount);
}
let mockTotpStatusQuery = jest.fn();
function mockApolloClientModule() {
mockTotpStatusUseQuery();
}
function mockTotpStatusUseQuery() {
mockTotpStatusQuery.mockImplementation(() => {
return {
data: MOCK_TOTP_STATUS,
loading: false,
};
});
jest.spyOn(ApolloModule, 'useQuery').mockReturnValue(mockTotpStatusQuery());
}
const MOCK_ROUTER_STATE_EMAIL = 'from@routerstate.com';
const MOCK_LOCATION_STATE_COMPLETE = {
email: MOCK_ROUTER_STATE_EMAIL,
verificationReason: VerificationReasons.SIGN_IN,
};
async function render() {
renderWithLocalizationProvider(
<LocationProvider>
<SigninTokenCodeContainer
{...{
integration,
}}
/>
</LocationProvider>
);
}
describe('SigninTokenCode container', () => {
beforeEach(() => {
applyDefaultMocks();
});
describe('initial states', () => {
describe('email', () => {
it('can be set from router state', async () => {
mockLocationState = MOCK_LOCATION_STATE_COMPLETE;
render();
await waitFor(() => {
expect(CacheModule.currentAccount).not.toBeCalled();
});
expect(currentSigninTokenCodeProps?.email).toBe(
MOCK_ROUTER_STATE_EMAIL
);
expect(currentSigninTokenCodeProps?.integration).toBe(integration);
expect(currentSigninTokenCodeProps?.verificationReason).toBe(
MOCK_LOCATION_STATE_COMPLETE.verificationReason
);
expect(SigninTokenCodeModule.default).toBeCalled();
});
it('router state takes precedence over local storage', async () => {
mockLocationState = MOCK_LOCATION_STATE_COMPLETE;
render();
expect(CacheModule.currentAccount).not.toBeCalled();
await waitFor(() => {
expect(currentSigninTokenCodeProps?.email).toBe(
MOCK_ROUTER_STATE_EMAIL
);
});
expect(SigninTokenCodeModule.default).toBeCalled();
});
it('is read from localStorage if email is not provided via router state', async () => {
mockCurrentAccount(MOCK_STORED_ACCOUNT);
render();
expect(CacheModule.currentAccount).toBeCalled();
await waitFor(() => {
expect(currentSigninTokenCodeProps?.email).toBe(
MOCK_STORED_ACCOUNT.email
);
});
expect(SigninTokenCodeModule.default).toBeCalled();
});
it('is handled if not provided in location state or local storage', async () => {
render();
expect(CacheModule.currentAccount).toBeCalled();
expect(ReactUtils.hardNavigateToContentServer).toBeCalledWith('/');
expect(SigninTokenCodeModule.default).not.toBeCalled();
});
});
describe('totp status', () => {
beforeEach(() => {
mockLocationState = MOCK_LOCATION_STATE_COMPLETE;
});
it('displays loading spinner when loading', () => {
mockTotpStatusQuery.mockImplementation(() => {
return {
data: null,
loading: true,
};
});
jest
.spyOn(ApolloModule, 'useQuery')
.mockReturnValue(mockTotpStatusQuery());
render();
expect(mockTotpStatusQuery).toBeCalled();
screen.getByLabelText('Loading…');
expect(SigninTokenCodeModule.default).not.toBeCalled();
});
it('redirects to totp screen if user has totp enabled', () => {
mockTotpStatusQuery.mockImplementation(() => ({
data: MOCK_TOTP_STATUS_VERIFIED,
loading: false,
}));
jest
.spyOn(ApolloModule, 'useQuery')
.mockReturnValue(mockTotpStatusQuery());
render();
expect(mockTotpStatusQuery).toBeCalled();
expect(mockNavigate).toBeCalledWith('/signin_totp_code');
expect(SigninTokenCodeModule.default).not.toBeCalled();
});
});
});
});

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

@ -0,0 +1,61 @@
/* 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 { RouteComponentProps, useLocation, useNavigate } from '@reach/router';
import {
SigninTokenCodeIntegration,
TotpResponse,
SigninLocationState,
} from './interfaces';
import SigninTokenCode from '.';
import { useQuery } from '@apollo/client';
import { GET_TOTP_STATUS } from '../../../components/App/gql';
import { currentAccount } from '../../../lib/cache';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import { hardNavigateToContentServer } from 'fxa-react/lib/utils';
// The email with token code (verifyLoginCodeEmail) is sent on `/signin`
// submission if conditions are met.
const SigninTokenCodeContainer = ({
integration,
}: {
integration: SigninTokenCodeIntegration;
} & RouteComponentProps) => {
const navigate = useNavigate();
const location = useLocation() as ReturnType<typeof useLocation> & {
state?: SigninLocationState;
};
// TODO: We may want to store "verificationReason" in local apollo
// cache instead of passing it via location state, depending on
// if we reference it in another spot or two and if we need
// some action to happen dependent on it that should occur
// without first reaching /signin.
const { email: emailFromLocationState, verificationReason } =
location.state || {};
// read from localStorage if email isn't provided via router state
const email = emailFromLocationState
? emailFromLocationState
: currentAccount()?.email;
// reads from cache if coming from /signin
const { data: totpData, loading: totpLoading } =
useQuery<TotpResponse>(GET_TOTP_STATUS);
if (!email) {
hardNavigateToContentServer('/');
return <LoadingSpinner fullScreen />;
}
if (totpLoading) {
return <LoadingSpinner fullScreen />;
}
if (totpData?.account.totp.verified) {
navigate('/signin_totp_code');
return <LoadingSpinner fullScreen />;
}
return <SigninTokenCode {...{ email, integration, verificationReason }} />;
};
export default SigninTokenCodeContainer;

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

@ -4,10 +4,10 @@
import React from 'react';
import SigninTokenCode from '.';
import AppLayout from '../../../components/AppLayout';
import { Meta } from '@storybook/react';
import { MOCK_ACCOUNT } from '../../../models/mocks';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { createMockWebIntegration } from './mocks';
export default {
title: 'Pages/Signin/SigninTokenCode',
@ -16,7 +16,8 @@ export default {
} as Meta;
export const Default = () => (
<AppLayout>
<SigninTokenCode email={MOCK_ACCOUNT.primaryEmail.email} />
</AppLayout>
<SigninTokenCode
email={MOCK_ACCOUNT.primaryEmail.email}
integration={createMockWebIntegration()}
/>
);

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

@ -3,14 +3,22 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import * as utils from 'fxa-react/lib/utils';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; // import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils';
// import { FluentBundle } from '@fluent/bundle';
import { usePageViewEvent } from '../../../lib/metrics';
import GleanMetrics from '../../../lib/glean';
import SigninTokenCode, { viewName } from '.';
import { MOCK_ACCOUNT } from '../../../models/mocks';
import { viewName } from '.';
import { mockAppContext, mockSession } from '../../../models/mocks';
import { REACT_ENTRYPOINT } from '../../../constants';
import { Session, AppContext } from '../../../models';
import { SigninTokenCodeIntegration } from './interfaces';
import { Subject } from './mocks';
import { MOCK_SIGNUP_CODE } from '../../Signup/ConfirmSignupCode/mocks';
import { MOCK_EMAIL } from '../../mocks';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import VerificationReasons from '../../../constants/verification-reasons';
jest.mock('../../../lib/metrics', () => ({
usePageViewEvent: jest.fn(),
@ -29,9 +37,37 @@ jest.mock('../../../lib/glean', () => ({
},
}));
describe('PageSigninTokenCode', () => {
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
}));
let session: Session;
function render({
integration,
verificationReason,
}: {
integration?: SigninTokenCodeIntegration;
verificationReason?: VerificationReasons;
} = {}) {
renderWithLocalizationProvider(
<AppContext.Provider value={mockAppContext({ session })}>
<Subject {...{ integration, verificationReason }} />
</AppContext.Provider>
);
}
describe('SigninTokenCode page', () => {
beforeEach(() => {
jest.resetAllMocks();
session = {
verifySession: jest.fn().mockResolvedValue(true),
sendVerificationCode: jest.fn().mockResolvedValue(true),
} as unknown as Session;
});
afterEach(() => {
jest.clearAllMocks();
});
// TODO: enable l10n tests when they've been updated to handle embedded tags in ftl strings
@ -42,44 +78,175 @@ describe('PageSigninTokenCode', () => {
// });
it('renders as expected', () => {
renderWithLocalizationProvider(
<SigninTokenCode email={MOCK_ACCOUNT.primaryEmail.email} />
);
render();
// testAllL10n(screen, bundle);
const headingEl = screen.getByRole('heading', { level: 1 });
expect(headingEl).toHaveTextContent(
'Enter confirmation code for your Mozilla account'
);
screen.getByText(
`Enter the code that was sent to ${MOCK_EMAIL} within 5 minutes.`
);
screen.getByLabelText('Enter 6-digit code');
screen.getByRole('button', { name: 'Confirm' });
screen.getByRole('button', { name: 'Email new code.' });
// initially hidden
expect(screen.queryByRole('Code expired?')).not.toBeInTheDocument();
});
it('emits a metrics event on render', () => {
renderWithLocalizationProvider(
<SigninTokenCode email={MOCK_ACCOUNT.primaryEmail.email} />
);
render();
expect(usePageViewEvent).toHaveBeenCalledWith(viewName, REACT_ENTRYPOINT);
expect(GleanMetrics.loginConfirmation.view).toBeCalledTimes(1);
});
// TODO at the time of the Glean metrics implementation the page is mostly a
// scaffold, without the code submission implementation. That is why a
// "submission" will always result in a success event.
it('emits metrics events on submit', async () => {
renderWithLocalizationProvider(
<SigninTokenCode email={MOCK_ACCOUNT.primaryEmail.email} />
);
fireEvent.input(screen.getByLabelText('Enter 6-digit code'), {
target: { value: '999000' },
describe('handleResendCode submission', () => {
async function renderAndResend() {
render();
await waitFor(() => {
screen.getByText('Code expired?');
});
fireEvent.click(screen.getByRole('button', { name: 'Email new code.' }));
await waitFor(() => {
expect(session.sendVerificationCode).toHaveBeenCalled();
});
}
it('on success, renders banner', async () => {
session = mockSession();
await renderAndResend();
screen.getByText('Email resent.', { exact: false });
});
fireEvent.click(screen.getByText('Confirm'));
it('on throttled error, renders banner with throttled message', async () => {
session = {
sendVerificationCode: jest
.fn()
.mockRejectedValue(AuthUiErrors.THROTTLED),
} as unknown as Session;
await renderAndResend();
screen.getByText('Youve tried too many times. Please try again later.');
});
it('on other error, renders banner with expected default error message', async () => {
session = {
sendVerificationCode: jest.fn().mockRejectedValue(new Error()),
} as unknown as Session;
await renderAndResend();
screen.getByText('Something went wrong. A new code could not be sent.');
});
});
await waitFor(() => {
describe('onSubmit code submission', () => {
function submit() {
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
}
function submitCode(code = MOCK_SIGNUP_CODE) {
fireEvent.change(screen.getByLabelText('Enter 6-digit code'), {
target: { value: code },
});
submit();
}
describe('does not submit and displays tooltip', () => {
beforeEach(() => {
render();
});
async function expectNoSubmission() {
await waitFor(() => {
expect(session.verifySession).not.toHaveBeenCalled();
expect(screen.getByTestId('tooltip')).toHaveTextContent(
'Confirmation code required'
);
expect(GleanMetrics.loginConfirmation.submit).not.toBeCalled();
});
}
it('if no input', async () => {
submit();
expectNoSubmission();
});
// Note, we don't have a test for more than 6 because the input doesn't allow this
it('if input length is less than 6', async () => {
// whitespace should get trimmed, so this should be a length of 5
submitCode('12345 ');
expectNoSubmission();
});
it('if input is not numeric', async () => {
submitCode('1234z5');
expectNoSubmission();
});
it('if input is scientific notation', async () => {
submitCode('100e10');
expectNoSubmission();
});
});
it('on throttled error, renders banner with throttled message', async () => {
session = {
verifySession: jest.fn().mockRejectedValue(AuthUiErrors.THROTTLED),
} as unknown as Session;
render();
submitCode();
await screen.findByText(
'Youve tried too many times. Please try again later.'
);
expect(GleanMetrics.loginConfirmation.submit).toBeCalledTimes(1);
expect(GleanMetrics.loginConfirmation.success).toBeCalledTimes(1);
expect(GleanMetrics.loginConfirmation.success).not.toBeCalled();
});
it('on other error, renders expected default error message in tooltip', async () => {
session = {
verifySession: jest
.fn()
.mockRejectedValue(AuthUiErrors.INVALID_EXPIRED_SIGNUP_CODE),
} as unknown as Session;
render();
submitCode();
expect(await screen.findByTestId('tooltip')).toHaveTextContent(
'Invalid or expired confirmation code'
);
expect(GleanMetrics.loginConfirmation.submit).toBeCalledTimes(1);
expect(GleanMetrics.loginConfirmation.success).not.toBeCalled();
});
describe('on success', () => {
let hardNavigateToContentServerSpy: jest.SpyInstance;
beforeEach(() => {
hardNavigateToContentServerSpy = jest
.spyOn(utils, 'hardNavigateToContentServer')
.mockImplementation(() => {});
});
afterEach(() => {
hardNavigateToContentServerSpy.mockRestore();
});
async function expectSuccessGleanEvents() {
await waitFor(() => {
expect(GleanMetrics.loginConfirmation.submit).toBeCalledTimes(1);
});
expect(GleanMetrics.loginConfirmation.success).toBeCalledTimes(1);
expect(GleanMetrics.isDone).toBeCalledTimes(1);
}
it('default behavior', async () => {
session = mockSession();
render();
submitCode();
await expectSuccessGleanEvents();
expect(mockNavigate).toHaveBeenCalledWith('/settings');
});
it('when verificationReason is a force password change', async () => {
session = mockSession();
render({ verificationReason: VerificationReasons.CHANGE_PASSWORD });
submitCode();
await expectSuccessGleanEvents();
expect(hardNavigateToContentServerSpy).toHaveBeenCalledWith(
'/post_verify/password/force_password_change'
);
});
// it('with sync integration', () => {
// // TODO with sync ticket
// });
// it('with OAuth integration', () => {
// // TODO with OAuth
// });
});
});
});

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

@ -2,10 +2,14 @@
* 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, useState } from 'react';
import { RouteComponentProps } from '@reach/router';
import { FtlMsg } from 'fxa-react/lib/utils';
import { /* useAccount, */ useFtlMsgResolver } from '../../../models';
import React, { useCallback, useEffect, useState } from 'react';
import { RouteComponentProps, useLocation, useNavigate } from '@reach/router';
import { FtlMsg, hardNavigateToContentServer } from 'fxa-react/lib/utils';
import {
isOAuthIntegration,
useFtlMsgResolver,
useSession,
} from '../../../models';
import { usePageViewEvent } from '../../../lib/metrics';
import { MailImage } from '../../../components/images';
import FormVerifyCode, {
@ -14,31 +18,50 @@ import FormVerifyCode, {
import { REACT_ENTRYPOINT } from '../../../constants';
import CardHeader from '../../../components/CardHeader';
import GleanMetrics from '../../../lib/glean';
// import { ResendStatus } from "fxa-settings/src/lib/types";
// import { ResendLinkErrorBanner, ResendEmailSuccessBanner } from "fxa-settings/src/components/Banner";
// email will eventually be obtained from account context
export type SigninTokenCodeProps = { email: string };
import AppLayout from '../../../components/AppLayout';
import { SigninTokenCodeProps } from './interfaces';
import {
AuthUiErrors,
getLocalizedErrorMessage,
} from '../../../lib/auth-errors/auth-errors';
import Banner, {
BannerProps,
BannerType,
ResendEmailSuccessBanner,
} from '../../../components/Banner';
import VerificationReasons from '../../../constants/verification-reasons';
export const viewName = 'signin-token-code';
const SIX_DIGIT_NUMBER_REGEX = /^\d{6}$/;
const SigninTokenCode = ({
integration,
email,
verificationReason,
}: SigninTokenCodeProps & RouteComponentProps) => {
usePageViewEvent(viewName, REACT_ENTRYPOINT);
const session = useSession();
const navigate = useNavigate();
const location = useLocation();
// const account = useAccount();
const [banner, setBanner] = useState<Partial<BannerProps>>({
type: undefined,
children: undefined,
});
const [animateBanner, setAnimateBanner] = useState(false);
const [codeErrorMessage, setCodeErrorMessage] = useState<string>('');
// const [resendStatus, setResendStatus] = useState<ResendStatus>(
// ResendStatus['not sent']
// );
const [resendCodeLoading, setResendCodeLoading] = useState<boolean>(false);
const ftlMsgResolver = useFtlMsgResolver();
const localizedCustomCodeRequiredMessage = ftlMsgResolver.getMsg(
'signin-token-code-required-error',
'Confirmation code required'
);
const localizedInvalidCode = getLocalizedErrorMessage(
ftlMsgResolver,
AuthUiErrors.INVALID_VERIFICATION_CODE
);
const formAttributes: FormAttributes = {
inputFtlId: 'signin-token-code-input-label-v2',
@ -53,50 +76,123 @@ const SigninTokenCode = ({
GleanMetrics.loginConfirmation.view();
}, []);
const handleResendCode = async () => {
// try {
// TODO: add resend code action
// await account.verifySessionResendCode();
// setResendStatus(ResendStatus['sent']);
// } catch (e) {
// setResendStatus(ResendStatus['error']);
// }
const handleAnimationEnd = () => {
// We add the "shake" animation to bring attention to the success banner
// when the success banner was already displayed. We have to remove the
// class once the animation completes or the animation won't replay.
setAnimateBanner(false);
};
const onSubmit = async () => {
const handleResendCode = async () => {
setResendCodeLoading(true);
try {
GleanMetrics.loginConfirmation.submit();
// Check confirmation code
// Log success event
// The await of isDone is not entirely necessary when we are not
// redirecting the user to an RP. However at the time of implementation
// for the Glean ping the redirect logic has not been implemented.
GleanMetrics.loginConfirmation.success();
await GleanMetrics.isDone();
// Check if isForcePasswordChange
} catch (e) {
// TODO: error handling, error message confirmation
// this should likely use auth-errors and display in a tooltip or banner
await session.sendVerificationCode();
if (banner.type === BannerType.success) {
setAnimateBanner(true);
} else {
setBanner({
type: BannerType.success,
});
}
} catch (error) {
const localizedErrorMessage =
error.errno === AuthUiErrors.THROTTLED.errno
? getLocalizedErrorMessage(ftlMsgResolver, error)
: ftlMsgResolver.getMsg(
'link-expired-resent-code-error-message',
'Something went wrong. A new code could not be sent.'
);
setBanner({
type: BannerType.error,
children: <p>{localizedErrorMessage}</p>,
});
} finally {
setResendCodeLoading(false);
}
};
return (
// TODO: redirect to force_auth or signin if user has not initiated sign in
const onSubmit = useCallback(
async (code: string) => {
if (!SIX_DIGIT_NUMBER_REGEX.test(code)) {
setCodeErrorMessage(localizedInvalidCode);
return;
}
// TODO: handle bounced email
// if the account no longer exists, redirect to sign up
// if the account exists, notify that the account has been blocked
// and provide correct support link
<>
GleanMetrics.loginConfirmation.submit();
try {
await session.verifySession(code);
// TODO: Bounced email redirect to `/signin_bounced`. Try
// reaching signin_token_code in one browser and deleting the account
// in another. You reach the "Sorry. We've locked your account" screen
GleanMetrics.loginConfirmation.success();
if (verificationReason === VerificationReasons.CHANGE_PASSWORD) {
GleanMetrics.isDone();
hardNavigateToContentServer(
`/post_verify/password/force_password_change${location.search}`
);
return;
}
if (integration.isSync()) {
// todo, sync stuff
// this might need to be separated into desktop v3 / oauth sync
}
if (isOAuthIntegration(integration)) {
// TODO: OAuth redirect stuff in oauth ticket
// The await of isDone is not entirely necessary when we are not
// redirecting the user to an RP. However at the time of implementation
// for the Glean ping the redirect logic has not been implemented.
await GleanMetrics.isDone();
} else {
GleanMetrics.isDone();
navigate('/settings');
}
} catch (error) {
const localizedErrorMessage = getLocalizedErrorMessage(
ftlMsgResolver,
error
);
if (error.errno === AuthUiErrors.THROTTLED.errno) {
setBanner({
type: BannerType.error,
children: <p>{localizedErrorMessage}</p>,
});
} else {
setCodeErrorMessage(localizedErrorMessage);
}
}
},
[
ftlMsgResolver,
localizedInvalidCode,
session,
integration,
navigate,
verificationReason,
location.search,
]
);
return (
<AppLayout>
<CardHeader
headingText="Enter confirmation code"
headingAndSubheadingFtlId="signin-token-code-heading-2"
/>
{/* {resendStatus === ResendStatus["sent"] && <ResendEmailSuccessBanner />}
{resendStatus === ResendStatus["error"] && <ResendCodeErrorBanner />} */}
{banner.type === BannerType.success && banner.children === undefined && (
<ResendEmailSuccessBanner
animation={{
handleAnimationEnd,
animate: animateBanner,
className: 'animate-shake',
}}
/>
)}
{banner.type && banner.children && (
<Banner type={banner.type}>{banner.children}</Banner>
)}
<div className="flex justify-center mx-auto">
<MailImage className="w-3/5" />
@ -124,12 +220,16 @@ const SigninTokenCode = ({
<p>Code expired?</p>
</FtlMsg>
<FtlMsg id="signin-token-code-resend-code-link">
<button id="resend" className="link-blue" onClick={handleResendCode}>
<button
className="link-blue"
onClick={handleResendCode}
disabled={resendCodeLoading}
>
Email new code.
</button>
</FtlMsg>
</div>
</>
</AppLayout>
);
};

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

@ -0,0 +1,26 @@
/* 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 VerificationReasons from '../../../constants/verification-reasons';
import { AccountTotp } from '../../../lib/interfaces';
import { Integration } from '../../../models';
export type SigninTokenCodeIntegration = Pick<Integration, 'type' | 'isSync'>;
export interface SigninTokenCodeProps {
email: string;
integration: SigninTokenCodeIntegration;
verificationReason?: VerificationReasons;
}
export interface TotpResponse {
account: {
totp: AccountTotp;
};
}
export interface SigninLocationState {
email?: string;
verificationReason?: VerificationReasons;
}

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

@ -0,0 +1,37 @@
/* 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 { LocationProvider } from '@reach/router';
import { IntegrationType } from '../../../models';
import { SigninTokenCodeIntegration } from './interfaces';
import SigninTokenCode from '.';
import { MOCK_EMAIL } from '../../mocks';
import VerificationReasons from '../../../constants/verification-reasons';
export function createMockWebIntegration(): SigninTokenCodeIntegration {
return {
type: IntegrationType.Web,
isSync: () => false,
};
}
export const Subject = ({
integration = createMockWebIntegration(),
verificationReason,
}: {
integration?: SigninTokenCodeIntegration;
verificationReason?: VerificationReasons;
}) => {
return (
<LocationProvider>
<SigninTokenCode
{...{
email: MOCK_EMAIL,
integration,
verificationReason,
}}
/>
</LocationProvider>
);
};

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

@ -65,7 +65,7 @@ function applyDefaultMocks() {
mockApolloClientModule();
mockLocationState = {};
mockSignupModule();
mockSigninModule();
mockModelsModule();
mockUseValidateModule({ queryParams: MOCK_QUERY_PARAM_MODEL_NO_VALUES });
mockCurrentAccount();
@ -196,7 +196,7 @@ function mockAvatarUseQuery() {
}
let currentSigninProps: SigninProps | undefined;
function mockSignupModule() {
function mockSigninModule() {
currentSigninProps = undefined;
jest
.spyOn(SigninModule, 'default')
@ -219,7 +219,7 @@ function mockCryptoModule() {
});
}
async function render() {
function render() {
renderWithLocalizationProvider(
<LocationProvider>
<SigninContainer
@ -463,7 +463,7 @@ describe('signin container', () => {
});
});
await render();
render();
await waitFor(async () => {
const result = await currentSigninProps?.beginSigninHandler(

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

@ -15,7 +15,7 @@ import { SigninQueryParams } from '../../models/pages/signin';
import { useCallback, useEffect, useState } from 'react';
import firefox from '../../lib/channels/firefox';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import { currentAccount, discardSessionToken } from '../../lib/cache';
import { cache, currentAccount, discardSessionToken } from '../../lib/cache';
import { useMutation, useQuery } from '@apollo/client';
import {
AVATAR_QUERY,
@ -413,7 +413,23 @@ const SigninContainer = ({
}: { authenticationMethods: AuthenticationMethods[] } =
await authClient.accountProfile(sessionToken);
const totpIsActive = authenticationMethods.includes(
AuthenticationMethods.OTP
);
if (totpIsActive) {
// Cache this for /signin_token_code and /settings
cache.modify({
id: cache.identify({ __typename: 'Account' }),
fields: {
totp() {
return { exists: true, verified: true };
},
},
});
}
// after accountProfile data is retrieved we must check verified status
// TODO: can we use the useSession hook here?
const {
verified,
sessionVerified,
@ -422,9 +438,7 @@ const SigninContainer = ({
sessionToken
);
const verificationMethod = authenticationMethods.includes(
AuthenticationMethods.OTP
)
const verificationMethod = totpIsActive
? VerificationMethods.TOTP_2FA
: VerificationMethods.EMAIL_OTP;

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

@ -249,7 +249,12 @@ describe('Signin', () => {
enterPasswordAndSubmit();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/signin_token_code');
expect(mockNavigate).toHaveBeenCalledWith('/signin_token_code', {
state: {
email: MOCK_EMAIL,
verificationReason: VerificationReasons.SIGN_IN,
},
});
});
});
it('navigates to /settings', async () => {

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

@ -141,14 +141,24 @@ const Signin = ({
// if (
// verificationMethod === VerificationMethods.EMAIL_OTP &&
// (verificationReason === VerificationReasons.SIGN_IN || verificationReason === VerificationReasons.CHANGE_PASSWORD)) {
navigate('/signin_token_code');
navigate('/signin_token_code', {
state: {
email,
// TODO: We may want to store this in local apollo cache
// instead of passing it via location state, depending on
// if we reference it in another spot or two and if we need
// some action to happen dependent on it that should occur
// without first reaching /signin.
verificationReason,
},
});
}
// Verified account, but session hasn't been verified
} else {
navigate('/settings');
}
},
[integration, isOAuth, navigate]
[integration, isOAuth, navigate, email]
);
const signInWithCachedAccount = useCallback(

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

@ -4,7 +4,7 @@
import VerificationMethods from '../../constants/verification-methods';
import VerificationReasons from '../../constants/verification-reasons';
import { AccountAvatar } from '../../lib/interfaces';
import { AccountAvatar, HandledError } from '../../lib/interfaces';
import { MozServices } from '../../lib/types';
import { Integration } from '../../models';
@ -63,10 +63,8 @@ export interface BeginSigninResultError {
verificationMethod?: VerificationMethods;
}
export type BeginSigninResultHandlerError = BeginSigninResultError & {
message: string;
ftlId: string;
};
export type BeginSigninResultHandlerError = BeginSigninResultError &
HandledError;
export interface BeginSigninResult {
data?: BeginSigninResponse | null;
@ -83,12 +81,6 @@ export interface RecoveryEmailStatusResponse {
emailVerified: boolean;
}
export interface CachedSigninHandlerError {
errno: number;
ftlId: string;
message: string;
}
export interface CachedSigninHandlerResponse {
data:
| ({
@ -96,7 +88,7 @@ export interface CachedSigninHandlerResponse {
verificationReason: VerificationReasons;
} & RecoveryEmailStatusResponse)
| null;
error?: CachedSigninHandlerError;
error?: HandledError;
}
export interface SigninFormData {

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

@ -20,7 +20,6 @@ import {
BeginSigninResponse,
BeginSigninResultHandlerError,
CachedSigninHandler,
CachedSigninHandlerError,
SigninIntegration,
SigninProps,
} from './interfaces';
@ -29,6 +28,26 @@ import {
AuthUiErrorNos,
AuthUiErrors,
} from '../../lib/auth-errors/auth-errors';
import { HandledError } from '../../lib/interfaces';
// TODO: There's some sharing opportunity with other parts of the codebase
// probably move these or a version of these to pages/mocks and share
export const MOCK_TOTP_STATUS_VERIFIED = {
account: {
totp: {
exists: true,
verified: true,
},
},
};
export const MOCK_TOTP_STATUS = {
account: {
totp: {
exists: true,
verified: false,
},
},
};
export function createMockSigninWebIntegration(): SigninIntegration {
return {
@ -95,7 +114,7 @@ export function createBeginSigninResponseError({
export function createCachedSigninResponseError({
errno = AuthUiErrors.SESSION_EXPIRED.errno!,
} = {}): {
error: CachedSigninHandlerError;
error: HandledError;
} {
const message = AuthUiErrorNos[errno].message;
return {

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

@ -2,6 +2,7 @@
* 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/interfaces';
import {
BaseIntegration,
IntegrationType,
@ -34,11 +35,7 @@ export type BeginSignupHandler = (
export interface BeginSignupResult {
data?: (BeginSignupResponse & { unwrapBKey: hexstring }) | null;
error?: {
errno: number;
message: string;
ftlId: string;
};
error?: HandledError;
}
export interface SignupProps {

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

@ -56,6 +56,11 @@ config.theme.extend = {
'60%': { transform: 'scale(.8)' },
'90%': { transform: 'scale(.9)' },
},
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-.25rem)' },
'20%, 40%, 60%, 80%': { transform: 'translateX(.25rem)' },
},
},
animation: {
...config.theme.extend.animation,
@ -66,6 +71,7 @@ config.theme.extend = {
'sparkle-lag-end': 'sparkle-lag-end 1.5s ease-in-out infinite',
'pulse-up': 'pulse-up 5s ease-in-out infinite',
heart: 'beat 1.5s infinite',
shake: 'shake 1s',
},
};