зеркало из https://github.com/mozilla/fxa.git
Merge pull request #16395 from mozilla/FXA-6490
feat(react): Recreate '/signin_token_code' page functionality in React
This commit is contained in:
Коммит
33f96a842c
|
@ -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('You’ve 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(
|
||||
'You’ve 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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче