Merge pull request #17493 from mozilla/update-push-ux

Add verify and confirm 2FA via push screens
This commit is contained in:
Vijay Budhram 2024-09-03 14:17:44 -04:00 коммит произвёл GitHub
Родитель 6e0dbaadc3 35b0311355
Коммит 6f4aa7a45c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
24 изменённых файлов: 1122 добавлений и 6 удалений

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

@ -1809,18 +1809,15 @@ export default class AuthClient {
}
async verifyLoginPushRequest(
email: string,
uid: string,
sessionToken: hexstring,
tokenVerificationId: string,
code: string,
headers?: Headers
): Promise<void> {
return await this.request(
'POST',
return this.sessionPost(
'/session/verify/verify_push',
sessionToken,
{
email,
uid,
tokenVerificationId,
code,
},

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

@ -557,6 +557,20 @@ Router = Router.extend({
}
);
},
'signin_push_code(/)': function () {
this.createReactViewHandler('signin_push_code', {
...Url.searchParams(this.window.location.search),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
});
},
'signin_push_code_confirm(/)': function () {
this.createReactViewHandler('signin_push_code_confirm', {
...Url.searchParams(this.window.location.search),
});
},
'signin_unblock(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_unblock',

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

@ -62,6 +62,8 @@ const FRONTEND_ROUTES = [
'signin',
'signin_bounced',
'signin_token_code',
'signin_push_code',
'signin_push_code_confirm',
'signin_totp_code',
'signin_recovery_code',
'signin_confirmed',

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

@ -84,6 +84,8 @@ const getReactRouteGroups = (showReactApp, reactRoute) => {
'signin_recovery_code',
'inline_totp_setup',
'inline_recovery_setup',
'signin_push_code',
'signin_push_code_confirm',
]),
fullProdRollout: true,
},

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

@ -70,6 +70,8 @@ import SigninRecoveryCodeContainer from '../../pages/Signin/SigninRecoveryCode/c
import SigninReported from '../../pages/Signin/SigninReported';
import SigninTokenCodeContainer from '../../pages/Signin/SigninTokenCode/container';
import SigninTotpCodeContainer from '../../pages/Signin/SigninTotpCode/container';
import SigninPushCodeContainer from '../../pages/Signin/SigninPushCode/container';
import SigninPushCodeConfirmContainer from '../../pages/Signin/SigninPushCodeConfirm/container';
import SigninUnblockContainer from '../../pages/Signin/SigninUnblock/container';
import ConfirmSignupCodeContainer from '../../pages/Signup/ConfirmSignupCode/container';
import SignupContainer from '../../pages/Signup/container';
@ -441,6 +443,14 @@ const AuthAndAccountSetupRoutes = ({
path="/signin_totp_code/*"
{...{ integration, serviceName }}
/>
<SigninPushCodeContainer
path="/signin_push_code/*"
{...{ integration, serviceName }}
/>
<SigninPushCodeConfirmContainer
path="/signin_push_code_confirm/*"
{...{ integration, serviceName }}
/>
<SigninConfirmed
path="/signin_verified/*"
{...{ isSignedIn, serviceName }}

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

После

Ширина:  |  Высота:  |  Размер: 49 KiB

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

@ -3,6 +3,7 @@ import { ReactComponent as HeartsBroken } from './graphic_hearts_broken.svg';
import { ReactComponent as HeartsVerified } from './graphic_hearts_verified.svg';
import { ReactComponent as RecoveryCodes } from './graphic_recovery_codes.svg';
import { ReactComponent as TwoFactorAuth } from './graphic_two_factor_auth.svg';
import { ReactComponent as PushFactorAuth } from './graphic_push_factor_auth.svg';
import { ReactComponent as Mail } from './graphic_mail.svg';
import { ReactComponent as SecurityShield } from './graphic_security_shield.svg';
import { ReactComponent as Key } from './graphic_key.svg';
@ -100,6 +101,15 @@ export const TwoFactorAuthImage = ({ className, ariaHidden }: ImageProps) => (
/>
);
export const PushAuthImage = ({ className, ariaHidden }: ImageProps) => (
<PreparedImage
ariaLabel="A device that recieved a push notification."
ariaLabelFtlId="signin-push-code-image-label"
Image={PushFactorAuth}
{...{ className, ariaHidden }}
/>
);
export const MailImage = ({ className, ariaHidden }: ImageProps) => (
<PreparedImage
ariaLabel="An envelope containing a link"

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

@ -0,0 +1,22 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { IsHexadecimal, IsString, Length } from 'class-validator';
import { bind, ModelDataProvider } from '../../../lib/model-data';
export class PushSigninQueryParams extends ModelDataProvider {
@IsHexadecimal()
@Length(32)
@bind()
tokenVerificationId: string = '';
@IsString()
@Length(6)
@bind()
code: string = '';
@IsString()
@bind()
remoteMetaData: string = '';
}

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

@ -0,0 +1,218 @@
/* 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 SigninPushCodeModule from '.';
import { SigninPushCodeProps } from './interfaces';
import * as ReactUtils from 'fxa-react/lib/utils';
import * as CacheModule from '../../../lib/cache';
import { Integration } from '../../../models';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { LocationProvider } from '@reach/router';
import SigninPushCodeContainer from './container';
import { waitFor } from '@testing-library/react';
import { MOCK_EMAIL, MOCK_STORED_ACCOUNT } from '../../mocks';
import {
createMockSigninLocationState,
createMockSyncIntegration,
} from './mocks';
import { act } from 'react-dom/test-utils';
import { MozServices } from '../../../lib/types';
let integration: Integration;
function mockSyncDesktopIntegration() {
integration = createMockSyncIntegration() as Integration;
}
function applyDefaultMocks() {
jest.resetAllMocks();
jest.restoreAllMocks();
mockReactUtilsModule();
mockSyncDesktopIntegration();
mockSigninPushCodeModule();
mockCurrentAccount();
}
let mockHasTotpAuthClient = false;
let mockSessionStatus = 'verified';
let mockSendLoginPushRequest = jest.fn().mockResolvedValue({});
jest.mock('../../../models', () => {
return {
...jest.requireActual('../../../models'),
useAuthClient: () => {
return {
checkTotpTokenExists: jest
.fn()
.mockResolvedValue({ verified: mockHasTotpAuthClient }),
sessionStatus: jest.fn().mockResolvedValue({
state: mockSessionStatus,
}),
sendLoginPushRequest: mockSendLoginPushRequest,
};
},
};
});
// Set this when testing location state
let mockLocationState = {};
const mockLocation = () => {
return {
pathname: '/signin_push_code',
state: mockLocationState,
};
};
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => {
return {
__esModule: true,
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
useLocation: () => mockLocation(),
};
});
let currentSigninPushCodeProps: SigninPushCodeProps | undefined;
let moduleMock: jest.SpyInstance;
function mockSigninPushCodeModule() {
currentSigninPushCodeProps = undefined;
moduleMock = jest
.spyOn(SigninPushCodeModule, 'default')
.mockImplementation((props: SigninPushCodeProps) => {
currentSigninPushCodeProps = props;
return <div>signin push code mock</div>;
});
}
function mockReactUtilsModule() {
jest.spyOn(ReactUtils, 'hardNavigate').mockImplementation(() => {});
}
// Set this when testing local storage
function mockCurrentAccount(storedAccount = { uid: '123' }) {
jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(storedAccount);
}
async function render() {
renderWithLocalizationProvider(
<LocationProvider>
<SigninPushCodeContainer
{...{
integration,
serviceName: MozServices.FirefoxSync,
}}
/>
</LocationProvider>
);
}
describe('SigninPushCode container', () => {
beforeEach(() => {
applyDefaultMocks();
});
describe('initial states', () => {
describe('email', () => {
it('can be set from router state', async () => {
mockLocationState = createMockSigninLocationState();
render();
await waitFor(() => {
expect(CacheModule.currentAccount).not.toBeCalled();
});
expect(currentSigninPushCodeProps?.signinState.email).toBe(MOCK_EMAIL);
expect(SigninPushCodeModule.default).toBeCalled();
});
it('router state takes precedence over local storage', async () => {
mockLocationState = createMockSigninLocationState();
render();
expect(CacheModule.currentAccount).not.toBeCalled();
await waitFor(() => {
expect(currentSigninPushCodeProps?.signinState.email).toBe(
MOCK_EMAIL
);
});
expect(SigninPushCodeModule.default).toBeCalled();
});
it('is read from localStorage if email is not provided via router state', async () => {
mockLocationState = {};
mockCurrentAccount(MOCK_STORED_ACCOUNT);
render();
expect(CacheModule.currentAccount).toBeCalled();
await waitFor(() => {
expect(currentSigninPushCodeProps?.signinState.email).toBe(
MOCK_STORED_ACCOUNT.email
);
});
expect(SigninPushCodeModule.default).toBeCalled();
});
it('is handled if not provided in location state or local storage', async () => {
mockLocationState = {};
render();
expect(CacheModule.currentAccount).toBeCalled();
expect(ReactUtils.hardNavigate).toBeCalledWith('/', {}, true);
expect(SigninPushCodeModule.default).not.toBeCalled();
});
});
describe('totp status', () => {
beforeEach(() => {
mockLocationState = createMockSigninLocationState();
});
it('redirects to totp screen if user has totp enabled', async () => {
mockHasTotpAuthClient = true;
await act(async () => {
await render();
});
await waitFor(() => {
expect(mockNavigate).toBeCalledWith('/signin_totp_code', {
state: mockLocationState,
});
});
});
it('does not redirect with totp false', async () => {
mockHasTotpAuthClient = false;
await act(async () => {
await render();
});
await waitFor(() => {
expect(mockNavigate).not.toBeCalled();
});
});
});
});
describe('render', () => {
beforeEach(() => {
moduleMock.mockRestore();
});
it('sends push notification', async () => {
mockSessionStatus = 'false';
mockLocationState = createMockSigninLocationState();
await act(async () => {
await render();
});
expect(mockSendLoginPushRequest).toBeCalled();
});
it('navigates when session verified', async () => {
mockSessionStatus = 'verified';
mockLocationState = createMockSigninLocationState();
await act(async () => {
await render();
});
expect(ReactUtils.hardNavigate).toBeCalledWith(
'/connect_another_device?showSuccessMessage=true'
);
});
});
});

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

@ -0,0 +1,131 @@
/* 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 } from '@reach/router';
import SigninPushCode from '.';
import { MozServices } from '../../../lib/types';
import { getSigninState, handleNavigation } from '../utils';
import { SigninLocationState } from '../interfaces';
import { Integration, isWebIntegration, useAuthClient } from '../../../models';
import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks';
import { hardNavigate } from 'fxa-react/lib/utils';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import OAuthDataError from '../../../components/OAuthDataError';
import { useWebRedirect } from '../../../lib/hooks/useWebRedirect';
import { useEffect, useState } from 'react';
import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery';
export type SigninPushCodeContainerProps = {
integration: Integration;
serviceName: MozServices;
};
export const SigninPushCodeContainer = ({
integration,
serviceName,
}: SigninPushCodeContainerProps & RouteComponentProps) => {
const authClient = useAuthClient();
const navigate = useNavigate();
const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler(
authClient,
integration
);
// TODO: FXA-9177, likely use Apollo cache here instead of location state
const location = useLocation() as ReturnType<typeof useLocation> & {
state: SigninLocationState;
};
const signinState = getSigninState(location.state);
const webRedirectCheck = useWebRedirect(integration.data.redirectTo);
const redirectTo =
isWebIntegration(integration) && webRedirectCheck.isValid()
? integration.data.redirectTo
: '';
const [totpVerified, setTotpVerified] = useState<boolean>(false);
useEffect(() => {
if (!signinState || !signinState.sessionToken) {
// case handled after the useEffect
return;
}
const getTotpStatus = async () => {
const { verified } = await authClient.checkTotpTokenExists(
signinState.sessionToken
);
setTotpVerified(verified);
};
getTotpStatus();
}, [authClient, signinState]);
if (oAuthDataError) {
return <OAuthDataError error={oAuthDataError} />;
}
if (!signinState) {
hardNavigate('/', {}, true);
return <LoadingSpinner fullScreen />;
}
// redirect if there is 2FA is set up for the account,
// but the session is not TOTP verified
if (totpVerified) {
navigate('/signin_totp_code', {
state: signinState,
});
return <LoadingSpinner fullScreen />;
}
const onCodeVerified = async () => {
const navigationOptions = {
email: signinState.email,
signinData: { ...signinState, verified: true },
unwrapBKey: signinState.unwrapBKey,
integration,
finishOAuthFlowHandler,
queryParams: location.search,
redirectTo,
};
await handleNavigation(navigationOptions);
};
const sendLoginPushNotification = async () => {
try {
const response = await authClient.sessionStatus(signinState.sessionToken);
if (response.state !== 'verified') {
await authClient.sendLoginPushRequest(signinState.sessionToken);
}
if (response.state === 'verified') {
await onCodeVerified();
}
} catch (error) {
console.error('Error sending push notification:', error);
}
};
const pollSessionStatus = async () => {
try {
const response = await authClient.sessionStatus(signinState.sessionToken);
if (response.state === 'verified') {
await onCodeVerified();
}
} catch (error) {
console.error('Error fetching session status:', error);
}
};
return (
<SigninPushCode
{...{
signinState,
serviceName,
sendLoginPushNotification,
pollSessionStatus,
}}
/>
);
};
export default SigninPushCodeContainer;

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

@ -0,0 +1,8 @@
## SigninPushCode page
## This page is used to send a push notification to the user's device for two-factor authentication (2FA).
signin-push-code-heading-w-default-service = Verify this login <span>to continue to account settings</span>
signin-push-code-heading-w-custom-service = Verify this login <span>to continue to { $serviceName }</span>
signin-push-code-instruction = Please check your other devices and approve this login from your Firefox browser.
signin-push-code-did-not-recieve = Didnt receive the notification?
signin-push-code-send-email-link = Email code

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

@ -0,0 +1,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import SigninPushCode from '.';
import { Meta } from '@storybook/react';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { Subject } from './mocks';
export default {
title: 'Pages/Signin/SigninPushCode',
component: SigninPushCode,
decorators: [withLocalization],
} as Meta;
export const Default = () => <Subject />;

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

@ -0,0 +1,66 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { screen } 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 { MozServices } from '../../../lib/types';
import { Subject } from './mocks';
const mockLocation = () => {
return {
pathname: '/signin_push_cpde',
};
};
jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
navigate: jest.fn(),
useLocation: () => mockLocation(),
}));
describe('Sign in with push notification code page', () => {
// TODO: enable l10n tests when they've been updated to handle embedded tags in ftl strings
// TODO: in FXA-6461
// let bundle: FluentBundle;
// beforeAll(async () => {
// bundle = await getFtlBundle('settings');
// });
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
it('renders as expected', () => {
renderWithLocalizationProvider(<Subject />);
// testAllL10n(screen, bundle);
const headingEl = screen.getByRole('heading', { level: 1 });
expect(headingEl).toHaveTextContent(
'Verify this login to continue to account settings'
);
screen.getByText(
'Please check your other devices and approve this login from your Firefox browser.'
);
screen.getByText('Didnt receive the notification?');
screen.getByRole('link', { name: 'Email code' });
});
it('shows the relying party in the header when a service name is provided', () => {
renderWithLocalizationProvider(
<Subject
{...{
serviceName: MozServices.MozillaVPN,
}}
/>
);
const headingEl = screen.getByRole('heading', { level: 1 });
expect(headingEl).toHaveTextContent(
'Verify this login to continue to Mozilla VPN'
);
});
});

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

@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import { Link, RouteComponentProps, useLocation } from '@reach/router';
import { FtlMsg } from 'fxa-react/lib/utils';
import { PushAuthImage } from '../../../components/images';
import CardHeader from '../../../components/CardHeader';
import AppLayout from '../../../components/AppLayout';
import { SigninPushCodeProps } from './interfaces';
export const viewName = 'signin-push-code';
const POLL_INTERVAL = 2000; // Poll every 2 seconds
const MAX_POLL_TIME = 300000; // 5 minutes
export const SigninPushCode = ({
signinState,
serviceName,
sendLoginPushNotification,
pollSessionStatus,
}: SigninPushCodeProps & RouteComponentProps) => {
const location = useLocation();
useEffect(() => {
sendLoginPushNotification();
const intervalId = setInterval(pollSessionStatus, POLL_INTERVAL);
const timeoutId = setTimeout(
() => clearInterval(intervalId),
MAX_POLL_TIME
);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
});
return (
<AppLayout>
<CardHeader
headingWithDefaultServiceFtlId="signin-push-code-heading-w-default-service"
headingWithCustomServiceFtlId="signin-push-code-heading-w-custom-service"
headingText="Verify this login"
{...{ serviceName }}
/>
<div className="flex justify-center mx-auto">
<PushAuthImage />
</div>
<FtlMsg id="signin-push-code-instruction">
<p className="my-5 text-sm">
Please check your other devices and approve this login from your
Firefox browser.
</p>
</FtlMsg>
<div className="mt-5 text-grey-500 text-xs inline-flex gap-1">
<FtlMsg id="signin-push-code-did-not-recieve">
<p>Didnt receive the notification?</p>
</FtlMsg>
<FtlMsg id="signin-push-code-send-email-link">
<Link
to={`/signin_token_code${location.search}`}
state={signinState}
className="link-blue"
>
Email code
</Link>
</FtlMsg>
</div>
</AppLayout>
);
};
export default SigninPushCode;

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

@ -0,0 +1,13 @@
/* 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 { SigninLocationState } from '../interfaces';
import { MozServices } from '../../../lib/types';
export type SigninPushCodeProps = {
signinState: SigninLocationState;
serviceName?: MozServices;
sendLoginPushNotification: () => void;
pollSessionStatus: () => void;
};

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

@ -0,0 +1,72 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { LocationProvider } from '@reach/router';
import { IntegrationType } from '../../../models';
import { SigninPushCode } from '.';
import { SigninPushCodeProps } from './interfaces';
import {
MOCK_EMAIL,
MOCK_KEY_FETCH_TOKEN,
MOCK_SESSION_TOKEN,
MOCK_UID,
MOCK_UNWRAP_BKEY,
} from '../../mocks';
import { MozServices } from '../../../lib/types';
import VerificationMethods from '../../../constants/verification-methods';
import VerificationReasons from '../../../constants/verification-reasons';
export const MOCK_LOCATION_STATE = {
email: MOCK_EMAIL,
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
verified: false,
verificationMethod: VerificationMethods.EMAIL_OTP,
};
export const createMockSigninLocationState = (
wantsKeys = false,
verificationReason?: VerificationReasons
) => {
return {
email: MOCK_EMAIL,
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
verified: false,
verificationReason,
...(wantsKeys && {
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
}),
};
};
export function createMockSyncIntegration() {
return {
type: IntegrationType.SyncDesktopV3,
getService: () => MozServices.FirefoxSync,
isSync: () => true,
wantsKeys: () => true,
data: {},
};
}
export const Subject = ({
serviceName = MozServices.Default,
signinState = MOCK_LOCATION_STATE,
}: Partial<SigninPushCodeProps>) => {
return (
<LocationProvider>
<SigninPushCode
{...{
serviceName,
signinState,
sendLoginPushNotification: () => Promise.resolve(),
pollSessionStatus: () => Promise.resolve(),
}}
/>
</LocationProvider>
);
};

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

@ -0,0 +1,85 @@
/* 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 ReactUtils from 'fxa-react/lib/utils';
import { SigninPushCodeConfirmContainer } from './container';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import { LocationProvider } from '@reach/router';
import * as UseValidateModule from '../../../lib/hooks/useValidate';
import { MOCK_HEXSTRING_32, MOCK_REMOTE_METADATA } from '../../mocks';
import { ModelDataProvider } from '../../../lib/model-data';
import { fireEvent, screen, waitFor } from '@testing-library/react';
function applyDefaultMocks() {
jest.resetAllMocks();
jest.restoreAllMocks();
mockUseValidateModule();
mockReactUtilsModule();
}
let mockVerifyLoginPushRequest = jest.fn().mockResolvedValue({});
jest.mock('../../../models', () => {
return {
...jest.requireActual('../../../models'),
useAuthClient: () => {
return {
verifyLoginPushRequest: mockVerifyLoginPushRequest,
};
},
};
});
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => {
return {
__esModule: true,
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
};
});
function mockUseValidateModule() {
jest.spyOn(UseValidateModule, 'useValidatedQueryParams').mockReturnValue({
queryParamModel: {
code: '123456',
tokenVerificationId: MOCK_HEXSTRING_32,
remoteMetaData: MOCK_REMOTE_METADATA,
} as unknown as ModelDataProvider,
validationError: undefined,
});
}
function mockReactUtilsModule() {
jest.spyOn(ReactUtils, 'hardNavigate').mockImplementation(() => {});
}
async function render(options = {}) {
renderWithLocalizationProvider(
<LocationProvider>
<SigninPushCodeConfirmContainer {...options} />
</LocationProvider>
);
}
describe('SigninPushCodeConfirm container', () => {
beforeEach(() => {
applyDefaultMocks();
});
it('can verify push notification', async () => {
render();
fireEvent.click(screen.getByText('Confirm login'));
await waitFor(() => {
expect(mockVerifyLoginPushRequest).toBeCalledWith(
null,
MOCK_HEXSTRING_32,
'123456'
);
});
screen.getByText('Your login has been approved. Please close this window.');
});
});

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

@ -0,0 +1,64 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useState } from 'react';
import { RouteComponentProps } from '@reach/router';
import { useAuthClient } from '../../../models';
import SigninPushCodeConfirm from './index';
import { useValidatedQueryParams } from '../../../lib/hooks/useValidate';
import { PushSigninQueryParams } from '../../../models/pages/signin/push-signin-query-params';
import { FtlMsg } from '../../../../../fxa-react/lib/utils';
import { sessionToken } from '../../../lib/cache';
export const SigninPushCodeConfirmContainer = (props: RouteComponentProps) => {
const authClient = useAuthClient();
const { queryParamModel, validationError } = useValidatedQueryParams(
PushSigninQueryParams
);
const { tokenVerificationId, code, remoteMetaData } = queryParamModel;
const remoteMetaDataParsed = JSON.parse(decodeURIComponent(remoteMetaData));
const [sessionVerified, setSessionVerified] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
if (validationError) {
return (
<div className="text-center mt-4">
<FtlMsg id="signin-push-code-confirm-link-error">
<p className="my-5 text-sm">Link is damaged. Please try again.</p>
</FtlMsg>
</div>
);
}
const handleSubmit = async () => {
setIsLoading(true);
try {
await authClient.verifyLoginPushRequest(
sessionToken()!,
tokenVerificationId,
code
);
setSessionVerified(true);
} catch (error) {
setErrorMessage('Error verifying login push request');
} finally {
setIsLoading(false);
}
};
return (
<SigninPushCodeConfirm
{...{
authDeviceInfo: remoteMetaDataParsed,
handleSubmit,
sessionVerified,
isLoading,
errorMessage,
}}
/>
);
};
export default SigninPushCodeConfirmContainer;

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

@ -0,0 +1,9 @@
## SigninPushCodeConfirmPage
signin-push-code-confirm-instruction = Confirm your login
signin-push-code-confirm-description = We detected a login attempt from the following device. If this was you, please approve the login
signin-push-code-confirm-verifying = Verifying
signin-push-code-confirm-login = Confirm login
signin-push-code-confirm-wasnt-me = This wasnt me, change password.
signin-push-code-confirm-login-approved = Your login has been approved. Please close this window.
signin-push-code-confirm-link-error = Link is damaged. Please try again.

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

@ -0,0 +1,11 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45" fill="#e0f7e4"/>
<path d="M30 50 L45 65 L70 35" fill="none" stroke="#4CAF50" stroke-width="8" stroke-linecap="round"
stroke-linejoin="round">
<animate attributeName="stroke-dasharray"
from="0 100"
to="100 0"
dur="1.5s"
fill="freeze"/>
</path>
</svg>

После

Ширина:  |  Высота:  |  Размер: 466 B

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

@ -0,0 +1,21 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import SigninPushCode from '.';
import { Meta } from '@storybook/react';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { Subject } from './mocks';
export default {
title: 'Pages/Signin/SigninPushCodeConfrim',
component: SigninPushCode,
decorators: [withLocalization],
} as Meta;
export const Default = () => <Subject />;
export const Verifying = () => <Subject isLoading={true} />;
export const SessionVerified = () => <Subject sessionVerified={true} />;

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

@ -0,0 +1,168 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { Link, RouteComponentProps } from '@reach/router';
import { RemoteMetadata } from '../../../lib/types';
import AppLayout from '../../../components/AppLayout';
import DeviceInfoBlock from '../../../components/DeviceInfoBlock';
import { FtlMsg } from '../../../../../fxa-react/lib/utils';
import Banner, { BannerType } from '../../../components/Banner';
// Reuse these images temporarily
import monitorIcon from '../../../components/Settings/BentoMenu/monitor.svg';
import relayIcon from '../../../components/Settings/BentoMenu/relay.svg';
import vpnIcon from '../../../components/Settings/BentoMenu/vpn-logo.svg';
import checkmarkIcon from './greencheck.svg';
import { LinkExternal } from 'fxa-react/components/LinkExternal';
export type SigninPushCodeConfirmProps = {
authDeviceInfo: RemoteMetadata;
handleSubmit: () => void;
sessionVerified: boolean;
isLoading: boolean;
errorMessage?: string;
};
const ProductPromotion = () => {
const products = [
{
icon: monitorIcon,
title: 'Mozilla Monitor',
description:
'Get notified if your information is involved in a data breach.',
link: 'https://monitor.firefox.com/',
},
{
icon: relayIcon,
title: 'Firefox Relay',
description:
'Keep your email address safe from spam and unwanted emails.',
link: 'https://relay.firefox.com/',
},
{
icon: vpnIcon,
title: 'Mozilla VPN',
description:
'Protect your internet connection and browse privately with Mozilla VPN.',
link: 'https://vpn.mozilla.org/',
},
];
// Note this isn't localized yet since the UX will most likely change
return (
<div className="mt-8">
<h2 className="text-lg font-bold mb-6">
Explore products from Mozilla that protect your privacy
</h2>
{products.map((product, index) => (
<div
key={index}
className="flex items-center justify-center my-6 space-x-4"
>
<img
src={product.icon}
alt={`${product.title} Logo`}
className="w-12 h-12"
/>
<div className="text-left max-w-xs">
<h3 className="text-md font-semibold mb-1">{product.title}</h3>
<p className="text-sm leading-relaxed">
{product.description}
<LinkExternal
href={product.link}
className="text-blue-500 underline"
>
{' '}
Learn more
</LinkExternal>
</p>
</div>
</div>
))}
</div>
);
};
const LoginApprovedMessage = () => {
return (
<div className="text-center mt-4">
<img
src={checkmarkIcon}
className="w-12 h-12 mx-auto mb-4"
alt="Checkmark Icon"
/>
<FtlMsg id="signin-push-code-confirm-login-approved">
<p className="my-5 text-sm">
Your login has been approved. Please close this window.
</p>
</FtlMsg>
<ProductPromotion />
</div>
);
};
const SigninPushCodeConfirm = ({
authDeviceInfo,
handleSubmit,
sessionVerified,
isLoading,
errorMessage,
}: SigninPushCodeConfirmProps & RouteComponentProps) => {
return (
<AppLayout>
{sessionVerified ? (
<LoginApprovedMessage />
) : (
<>
<FtlMsg id="signin-push-code-confirm-instruction">
<h1 className="card-header">Confirm your login</h1>
</FtlMsg>
{errorMessage && (
<Banner type={BannerType.error}>{errorMessage || ''}</Banner>
)}
<FtlMsg id="signin-push-code-confirm-description">
<p className="my-5 text-sm">
We detected a login attempt from the following device. If this was
you, please approve the login.
</p>
</FtlMsg>
<DeviceInfoBlock remoteMetadata={authDeviceInfo} />
<div className="flex flex-col justify-center mt-6">
<button
id="signin-push-code-confirm-login-button"
className="cta-primary cta-xl w-full"
onClick={handleSubmit}
>
{isLoading ? (
<FtlMsg id="signin-push-code-confirm-verifying">
Verifying
</FtlMsg>
) : (
<FtlMsg id="signin-push-code-confirm-login">
Confirm login
</FtlMsg>
)}
</button>
<FtlMsg id="signin-push-code-confirm-wasnt-me">
<Link
to="/settings/change_password"
className="link-grey mt-4 text-sm"
>
This wasnt me, change password.
</Link>
</FtlMsg>
</div>
</>
)}
</AppLayout>
);
};
export default SigninPushCodeConfirm;

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

@ -0,0 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import SigninPushCodeConfirm, { SigninPushCodeConfirmProps } from '.';
export const Subject = ({
authDeviceInfo,
sessionVerified,
isLoading,
}: Partial<SigninPushCodeConfirmProps>) => {
return (
<SigninPushCodeConfirm
sessionVerified={sessionVerified || false}
isLoading={isLoading || false}
handleSubmit={() => {}}
authDeviceInfo={
authDeviceInfo || {
deviceName: 'MacBook Pro',
deviceFamily: 'Device Family',
deviceOS: 'Device OS',
ipAddress: '123.123.123.123',
city: 'City',
region: 'Region',
country: 'Country',
}
}
/>
);
};

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

@ -65,3 +65,4 @@ export function mockLoadingSpinnerModule() {
});
}
export const MOCK_RECOVERY_KEY = 'ARJDF300TFEPRJ7SFYB8QVNVYT60WWS2';
export const MOCK_REMOTE_METADATA = JSON.stringify({});