зеркало из https://github.com/mozilla/fxa.git
Merge pull request #17493 from mozilla/update-push-ux
Add verify and confirm 2FA via push screens
This commit is contained in:
Коммит
6f4aa7a45c
|
@ -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 = Didn’t 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('Didn’t 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>Didn’t 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 wasn’t 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 wasn’t 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({});
|
||||
|
|
Загрузка…
Ссылка в новой задаче