Merge pull request #17583 from mozilla/FXA-10391

cleanup(settings): Remove unused reset password with link
This commit is contained in:
Valerie Pomerleau 2024-09-12 15:55:47 -07:00 коммит произвёл GitHub
Родитель e34a329f85 f0bb1b8001
Коммит a35c91da3b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
45 изменённых файлов: 28 добавлений и 4351 удалений

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

@ -19,7 +19,7 @@ import { firefox } from '../../lib/channels/firefox';
import * as MetricsFlow from '../../lib/metrics-flow';
import GleanMetrics from '../../lib/glean';
import * as Metrics from '../../lib/metrics';
import { LinkType, MozServices } from '../../lib/types';
import { MozServices } from '../../lib/types';
import {
Integration,
@ -34,7 +34,6 @@ import {
initializeSettingsContext,
SettingsContext,
} from '../../models/contexts/SettingsContext';
import { CreateCompleteResetPasswordLink } from '../../models/reset-password/verification/factory';
import { hardNavigate } from 'fxa-react/lib/utils';
@ -54,13 +53,12 @@ import Legal from '../../pages/Legal';
import LegalPrivacy from '../../pages/Legal/Privacy';
import LegalTerms from '../../pages/Legal/Terms';
import ThirdPartyAuthCallback from '../../pages/PostVerify/ThirdPartyAuthCallback';
import ResetPassword from '../../pages/ResetPassword';
import AccountRecoveryConfirmKey from '../../pages/ResetPassword/AccountRecoveryConfirmKey';
import AccountRecoveryResetPasswordContainer from '../../pages/ResetPassword/AccountRecoveryResetPassword/container';
import CompleteResetPasswordContainer from '../../pages/ResetPassword/CompleteResetPassword/container';
import ConfirmResetPassword from '../../pages/ResetPassword/ConfirmResetPassword';
import ResetPasswordConfirmed from '../../pages/ResetPassword/ResetPasswordConfirmed';
import ResetPasswordWithRecoveryKeyVerified from '../../pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified';
import ResetPasswordContainer from '../../pages/ResetPasswordRedesign/ResetPassword/container';
import ConfirmResetPasswordContainer from '../../pages/ResetPasswordRedesign/ConfirmResetPassword/container';
import CompleteResetPasswordContainer from '../../pages/ResetPasswordRedesign/CompleteResetPassword/container';
import AccountRecoveryConfirmKeyContainer from '../../pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/container';
import ResetPasswordConfirmed from '../../pages/ResetPasswordRedesign/ResetPasswordConfirmed';
import ResetPasswordWithRecoveryKeyVerified from '../../pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified';
import CompleteSigninContainer from '../../pages/Signin/CompleteSignin/container';
import SigninContainer from '../../pages/Signin/container';
import ReportSigninContainer from '../../pages/Signin/ReportSignin/container';
@ -78,11 +76,6 @@ import SignupContainer from '../../pages/Signup/container';
import PrimaryEmailVerified from '../../pages/Signup/PrimaryEmailVerified';
import SignupConfirmed from '../../pages/Signup/SignupConfirmed';
import WebChannelExample from '../../pages/WebChannelExample';
import LinkValidator from '../LinkValidator';
import ResetPasswordContainer from '../../pages/ResetPasswordRedesign/ResetPassword/container';
import ConfirmResetPasswordContainer from '../../pages/ResetPasswordRedesign/ConfirmResetPassword/container';
import CompleteResetPasswordWithCodeContainer from '../../pages/ResetPasswordRedesign/CompleteResetPassword/container';
import AccountRecoveryConfirmKeyContainer from '../../pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/container';
import SignoutSync from '../Settings/SignoutSync';
const Settings = lazy(() => import('../Settings'));
@ -300,7 +293,6 @@ const AuthAndAccountSetupRoutes = ({
integration: Integration;
flowQueryParams: QueryParams;
} & RouteComponentProps) => {
const config = useConfig();
const localAccount = currentAccount();
// TODO: MozServices / string discrepancy, FXA-6802
const serviceName = integration.getServiceName() as MozServices;
@ -331,68 +323,25 @@ const AuthAndAccountSetupRoutes = ({
/>
{/* Reset password */}
{config.featureFlags?.resetPasswordWithCode === true ? (
<>
<ResetPasswordContainer
path="/reset_password/*"
{...{ flowQueryParams, serviceName }}
/>
<ConfirmResetPasswordContainer path="/confirm_reset_password/*" />
<CompleteResetPasswordWithCodeContainer
path="/complete_reset_password/*"
{...{ integration }}
/>
<CompleteResetPasswordWithCodeContainer
path="/account_recovery_reset_password/*"
{...{ integration }}
/>
<AccountRecoveryConfirmKeyContainer
path="/account_recovery_confirm_key/*"
{...{
serviceName,
}}
/>
</>
) : (
<>
<ResetPassword
path="/reset_password/*"
{...{ integration, flowQueryParams }}
/>
<ConfirmResetPassword
path="/confirm_reset_password/*"
{...{ integration }}
/>
<CompleteResetPasswordContainer
path="/complete_reset_password/*"
{...{ integration }}
/>
<LinkValidator
path="/account_recovery_confirm_key/*"
linkType={LinkType['reset-password']}
viewName="account-recovery-confirm-key"
createLinkModel={() => {
return CreateCompleteResetPasswordLink();
}}
{...{ integration }}
>
{({ setLinkStatus, linkModel }) => (
<AccountRecoveryConfirmKey
{...{
setLinkStatus,
linkModel,
integration,
}}
/>
)}
</LinkValidator>
<AccountRecoveryResetPasswordContainer
path="/account_recovery_reset_password/*"
{...{ integration }}
/>
</>
)}
<ResetPasswordContainer
path="/reset_password/*"
{...{ flowQueryParams, serviceName }}
/>
<ConfirmResetPasswordContainer path="/confirm_reset_password/*" />
<CompleteResetPasswordContainer
path="/complete_reset_password/*"
{...{ integration }}
/>
<CompleteResetPasswordContainer
path="/account_recovery_reset_password/*"
{...{ integration }}
/>
<AccountRecoveryConfirmKeyContainer
path="/account_recovery_confirm_key/*"
{...{
serviceName,
}}
/>
<ResetPasswordWithRecoveryKeyVerified
path="/reset_password_with_recovery_key_verified/*"
{...{ integration, isSignedIn }}

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

@ -1,85 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { RouteComponentProps } from '@reach/router';
import React, { useState } from 'react';
import { LinkStatus, LinkType } from '../../lib/types';
import { ResetPasswordLinkDamaged } from '../LinkDamaged';
import { LinkExpiredResetPassword } from '../LinkExpiredResetPassword';
import { IntegrationType, isOAuthIntegration } from '../../models';
interface LinkValidatorChildrenProps<TModel> {
setLinkStatus: React.Dispatch<React.SetStateAction<LinkStatus>>;
linkModel: TModel;
}
interface LinkValidatorIntegration {
type: IntegrationType;
}
interface LinkValidatorProps<TModel> {
linkType: LinkType;
viewName: string;
createLinkModel: () => TModel;
integration: LinkValidatorIntegration;
children: (props: LinkValidatorChildrenProps<TModel>) => React.ReactNode;
}
interface LinkModel {
isValid(): boolean;
email: string | undefined;
}
const LinkValidator = <TModel extends LinkModel>({
linkType,
viewName,
integration,
createLinkModel,
children,
}: LinkValidatorProps<TModel> & RouteComponentProps) => {
// If `LinkValidator` is a route component receiving `path, then `children`
// is a React.ReactElement
const child = React.isValidElement(children)
? (children as React.ReactElement).props.children
: children;
const linkModel = createLinkModel();
const isValid = linkModel.isValid();
const email = linkModel.email;
const [linkStatus, setLinkStatus] = useState<LinkStatus>(
isValid ? LinkStatus.valid : LinkStatus.damaged
);
if (
linkStatus === LinkStatus.damaged &&
linkType === LinkType['reset-password']
) {
return <ResetPasswordLinkDamaged />;
}
if (
linkStatus === LinkStatus.expired &&
linkType === LinkType['reset-password'] &&
email !== undefined
) {
if (isOAuthIntegration(integration)) {
const service = integration.getService();
const redirectUri = integration.getRedirectUri();
return (
<LinkExpiredResetPassword
{...{ viewName, email, service, redirectUri }}
/>
);
}
return <LinkExpiredResetPassword {...{ viewName, email }} />;
}
return <>{child({ setLinkStatus, linkModel })}</>;
};
export default LinkValidator;

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

@ -88,7 +88,6 @@ export interface Config {
};
featureFlags?: {
keyStretchV2?: boolean;
resetPasswordWithCode?: boolean;
recoveryCodeSetupOnSyncSignIn?: boolean;
};
}
@ -166,7 +165,6 @@ export function getDefault() {
signInRoutes: false,
},
featureFlags: {
resetPasswordWithCode: false,
recoveryCodeSetupOnSyncSignIn: false,
},
} as Config;

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

@ -1,88 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { Account } from '../../../models';
import AccountRecoveryConfirmKey from '.';
import { Meta } from '@storybook/react';
import { withLocalization } from 'fxa-react/lib/storybooks';
import {
createMockOAuthIntegration,
createMockWebIntegration,
getSubject,
mockCompleteResetPasswordParams,
paramsWithMissingEmail,
} from './mocks';
import { produceComponent } from '../../../models/mocks';
export default {
title: 'Pages/ResetPassword/AccountRecoveryConfirmKey',
component: AccountRecoveryConfirmKey,
decorators: [withLocalization],
} as Meta;
function renderStory(
{
account = accountValid,
params = mockCompleteResetPasswordParams,
integration = createMockWebIntegration(),
} = {},
storyName?: string
) {
const { Subject, history, appCtx } = getSubject(account, params, integration);
const story = () => produceComponent(<Subject />, { history }, appCtx);
story.storyName = storyName;
return story();
}
const accountValid = {
resetPasswordStatus: () => Promise.resolve(true),
getRecoveryKeyBundle: () => Promise.resolve(true),
verifyPasswordForgotToken: () => Promise.resolve(false),
} as unknown as Account;
const accountWithExpiredLink = {
resetPasswordStatus: () => Promise.resolve(false),
} as unknown as Account;
const accountWithInvalidRecoveryKey = {
resetPasswordStatus: () => Promise.resolve(true),
getRecoveryKeyBundle: () => {
throw Error('boop');
},
verifyPasswordForgotToken: () => Promise.resolve(true),
} as unknown as Account;
export const OnConfirmValidKey = () => {
return renderStory(
{},
'Valid recovery key (32 characters). Users will be redirected to AccountRecoveryResetPassword on confirm'
);
};
export const OnConfirmInvalidKey = () => {
return renderStory({
account: accountWithInvalidRecoveryKey,
params: mockCompleteResetPasswordParams,
});
};
export const ThroughRelyingParty = () => {
return renderStory({
integration: createMockOAuthIntegration(),
});
};
export const OnConfirmLinkExpired = () => {
return renderStory({
account: accountWithExpiredLink,
params: mockCompleteResetPasswordParams,
});
};
export const WithDamagedLink = () => {
return renderStory({
params: paramsWithMissingEmail,
});
};

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

@ -1,443 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
// import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils';
// import { FluentBundle } from '@fluent/bundle';
import { logPageViewEvent, logViewEvent } from '../../../lib/metrics';
import GleanMetrics from '../../../lib/glean';
import { viewName } from '.';
import {
MOCK_RECOVERY_KEY,
MOCK_RESET_TOKEN,
MOCK_RECOVERY_KEY_ID,
MOCK_KB,
mockCompleteResetPasswordParams,
paramsWithMissingToken,
paramsWithMissingCode,
paramsWithMissingEmail,
getSubject,
createMockWebIntegration,
createMockOAuthIntegration,
} from './mocks';
import { REACT_ENTRYPOINT } from '../../../constants';
import { Account } from '../../../models';
import { typeByLabelText } from '../../../lib/test-utils';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { MOCK_ACCOUNT, renderWithRouter } from '../../../models/mocks';
import { MozServices } from '../../../lib/types';
import { MOCK_SERVICE } from '../../mocks';
jest.mock('../../../lib/metrics', () => ({
logPageViewEvent: jest.fn(),
logViewEvent: jest.fn(),
}));
jest.mock('../../../lib/glean', () => ({
passwordReset: {
recoveryKeyView: jest.fn(),
recoveryKeySubmit: jest.fn(),
},
}));
const mockNavigate = jest.fn();
const mockSearchParams = {
email: mockCompleteResetPasswordParams.email,
emailToHashWith: mockCompleteResetPasswordParams.emailToHashWith,
token: mockCompleteResetPasswordParams.token,
code: mockCompleteResetPasswordParams.code,
uid: mockCompleteResetPasswordParams.uid,
};
const search = new URLSearchParams(mockSearchParams);
const mockLocation = () => {
return {
pathname: `/account_recovery_confirm_key`,
search,
};
};
jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
useLocation: () => mockLocation(),
}));
jest.mock('fxa-auth-client/lib/recoveryKey', () => ({
decryptRecoveryKeyData: jest.fn(() => ({
kB: MOCK_KB,
})),
}));
const mockSetData = jest.fn();
jest.mock('../../../models', () => {
return {
...jest.requireActual('../../../models'),
useSensitiveDataClient: () => {
return {
getData: jest.fn(),
setData: mockSetData,
};
},
};
});
const accountWithValidResetToken = {
resetPasswordStatus: jest.fn().mockResolvedValue(true),
getRecoveryKeyBundle: jest.fn().mockResolvedValue({
recoveryData: 'mockRecoveryData',
recoveryKeyId: MOCK_RECOVERY_KEY_ID,
}),
passwordForgotVerifyCode: jest.fn().mockResolvedValue(MOCK_RESET_TOKEN),
} as unknown as Account;
const renderSubject = ({
account = accountWithValidResetToken,
params = mockCompleteResetPasswordParams,
integration = createMockWebIntegration(),
} = {}) => {
const { Subject, history, appCtx } = getSubject(account, params, integration);
return renderWithRouter(<Subject />, { history }, appCtx);
};
describe('PageAccountRecoveryConfirmKey', () => {
// 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');
// });
it('renders as expected when the link is valid', async () => {
renderSubject();
// testAllL10n(screen, bundle);
await screen.findByRole('heading', {
level: 1,
name: 'Reset password with account recovery key to continue to account settings',
});
screen.getByText(
'Please enter the one time use account recovery key you stored in a safe place to regain access to your Mozilla account.'
);
screen.getByTestId('warning-message-container');
screen.getByLabelText('Enter account recovery key');
screen.getByRole('button', { name: 'Confirm account recovery key' });
screen.getByRole('link', {
name: 'Dont have an account recovery key?',
});
});
describe('serviceName', () => {
it('renders the default', async () => {
renderSubject();
await screen.findByText(`to continue to ${MozServices.Default}`);
});
it('renders non-default', async () => {
renderSubject({ integration: createMockOAuthIntegration() });
await screen.findByText(`to continue to ${MOCK_SERVICE}`);
});
});
it('renders the component as expected when provided with an expired link', async () => {
const accountWithTokenError = {
resetPasswordStatus: jest.fn().mockResolvedValue(false),
passwordForgotVerifyCode: jest.fn().mockImplementation(() => {
throw AuthUiErrors.INVALID_TOKEN;
}),
} as unknown as Account;
renderSubject({ account: accountWithTokenError });
await screen.findByRole('heading', {
name: 'Reset password link expired',
});
});
describe('renders the component as expected when provided with a damaged link', () => {
let mockConsoleWarn: jest.SpyInstance;
beforeEach(() => {
// We expect that model bindings will warn us about missing / incorrect values.
// We don't want these warnings to effect test output since they are expected, so we
// will mock the function, and make sure it's called.
mockConsoleWarn = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
(GleanMetrics.passwordReset.recoveryKeyView as jest.Mock).mockReset();
});
afterEach(() => {
mockConsoleWarn.mockRestore();
});
it('with missing token', async () => {
renderSubject({ params: paramsWithMissingToken });
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
screen.getByText(
'The link you clicked was missing characters, and may have been broken by your email client. Copy the address carefully, and try again.'
);
expect(mockConsoleWarn).toBeCalled();
expect(GleanMetrics.passwordReset.recoveryKeyView).not.toHaveBeenCalled();
});
it('with missing code', async () => {
renderSubject({ params: paramsWithMissingCode });
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
expect(mockConsoleWarn).toBeCalled();
expect(GleanMetrics.passwordReset.recoveryKeyView).not.toHaveBeenCalled();
});
it('with missing email', async () => {
renderSubject({ params: paramsWithMissingEmail });
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
expect(mockConsoleWarn).toBeCalled();
expect(GleanMetrics.passwordReset.recoveryKeyView).not.toHaveBeenCalled();
});
});
describe('submit', () => {
it('does not allow submission with an empty input', async () => {
renderSubject();
const submitButton = await screen.findByRole('button', {
name: 'Confirm account recovery key',
});
expect(submitButton).toBeDisabled();
// adding text in the field enables the submit button
await typeByLabelText('Enter account recovery key')('a');
expect(
screen.getByRole('button', {
name: 'Confirm account recovery key',
})
).not.toBeDisabled();
});
it('with less than 32 characters', async () => {
renderSubject();
const submitButton = await screen.findByRole('button', {
name: 'Confirm account recovery key',
});
await typeByLabelText('Enter account recovery key')(
MOCK_RECOVERY_KEY.slice(0, -1)
);
fireEvent.click(submitButton);
await screen.findByText('Invalid account recovery key');
expect(
accountWithValidResetToken.getRecoveryKeyBundle
).not.toHaveBeenCalled();
// clears the error onchange
await typeByLabelText('Enter account recovery key')('');
expect(
screen.queryByText('Invalid account recovery key')
).not.toBeInTheDocument();
});
it('displays an error and does not submit with more than 32 characters', async () => {
renderSubject({ account: accountWithValidResetToken });
const submitButton = await screen.findByRole('button', {
name: 'Confirm account recovery key',
});
await typeByLabelText('Enter account recovery key')(
`${MOCK_RECOVERY_KEY}V`
);
fireEvent.click(submitButton);
await screen.findByText('Invalid account recovery key');
expect(
accountWithValidResetToken.getRecoveryKeyBundle
).not.toHaveBeenCalled();
});
it('displays an error and does not submit with invalid Crockford base32', async () => {
renderSubject();
const submitButton = await screen.findByRole('button', {
name: 'Confirm account recovery key',
});
await typeByLabelText('Enter account recovery key')(
`${MOCK_RECOVERY_KEY}L`.slice(1)
);
fireEvent.click(submitButton);
await screen.findByText('Invalid account recovery key');
expect(
accountWithValidResetToken.getRecoveryKeyBundle
).not.toHaveBeenCalled();
});
});
it('submits successfully with spaces in recovery key', async () => {
renderSubject();
const submitButton = await screen.findByRole('button', {
name: 'Confirm account recovery key',
});
await typeByLabelText('Enter account recovery key')(
MOCK_RECOVERY_KEY.replace(/(.{4})/g, '$1 ')
);
fireEvent.click(submitButton);
await waitFor(() => {
expect(
accountWithValidResetToken.getRecoveryKeyBundle
).toHaveBeenCalledWith(
MOCK_RESET_TOKEN,
MOCK_RECOVERY_KEY,
MOCK_ACCOUNT.uid
);
expect(mockSetData).toHaveBeenCalledWith('reset', { kB: MOCK_KB });
expect(mockNavigate).toHaveBeenCalledWith(
`/account_recovery_reset_password?${search}`,
{
state: {
accountResetToken: MOCK_RESET_TOKEN,
recoveryKeyId: MOCK_RECOVERY_KEY_ID,
},
}
);
});
});
it('submits successfully after invalid recovery key submission', async () => {
const accountWithKeyInvalidOnce = {
resetPasswordStatus: jest.fn().mockResolvedValue(true),
passwordForgotVerifyCode: jest.fn().mockResolvedValue(MOCK_RESET_TOKEN),
getRecoveryKeyBundle: jest
.fn()
.mockImplementationOnce(() => {
return Promise.reject(AuthUiErrors.INVALID_RECOVERY_KEY);
})
.mockResolvedValue({
recoveryData: 'mockRecoveryData',
recoveryKeyId: MOCK_RECOVERY_KEY_ID,
}),
} as unknown as Account;
renderSubject({ account: accountWithKeyInvalidOnce });
await screen.findByRole('heading', {
level: 1,
name: 'Reset password with account recovery key to continue to account settings',
});
await typeByLabelText('Enter account recovery key')(MOCK_RECOVERY_KEY);
fireEvent.click(
screen.getByRole('button', { name: 'Confirm account recovery key' })
);
await screen.findByText('Invalid account recovery key');
fireEvent.click(
screen.getByRole('button', { name: 'Confirm account recovery key' })
);
// only ever calls `passwordForgotVerifyCode` once despite number of submissions
await waitFor(() =>
expect(
accountWithKeyInvalidOnce.passwordForgotVerifyCode
).toHaveBeenCalledTimes(1)
);
expect(
accountWithKeyInvalidOnce.passwordForgotVerifyCode
).toHaveBeenCalledWith(
mockCompleteResetPasswordParams.token,
mockCompleteResetPasswordParams.code,
true
);
expect(
accountWithKeyInvalidOnce.getRecoveryKeyBundle
).toHaveBeenCalledTimes(2);
expect(accountWithKeyInvalidOnce.getRecoveryKeyBundle).toHaveBeenCalledWith(
MOCK_RESET_TOKEN,
MOCK_RECOVERY_KEY,
mockCompleteResetPasswordParams.uid
);
});
describe('emits metrics events', () => {
beforeEach(() => {
(GleanMetrics.passwordReset.recoveryKeyView as jest.Mock).mockReset();
(GleanMetrics.passwordReset.recoveryKeySubmit as jest.Mock).mockReset();
});
afterEach(() => jest.clearAllMocks());
it('on engage, submit, success', async () => {
renderSubject();
const submitButton = await screen.findByRole('button', {
name: 'Confirm account recovery key',
});
expect(logPageViewEvent).toHaveBeenCalledWith(viewName, REACT_ENTRYPOINT);
expect(
GleanMetrics.passwordReset.recoveryKeyView as jest.Mock
).toHaveBeenCalledTimes(1);
await typeByLabelText('Enter account recovery key')(MOCK_RECOVERY_KEY);
expect(logViewEvent).toHaveBeenCalledWith(
'flow',
`${viewName}.engage`,
REACT_ENTRYPOINT
);
fireEvent.click(submitButton);
await waitFor(() => {
expect(logViewEvent).toHaveBeenCalledWith(
'flow',
`${viewName}.submit`,
REACT_ENTRYPOINT
);
expect(logViewEvent).toHaveBeenCalledWith(
'flow',
`${viewName}.success`,
REACT_ENTRYPOINT
);
expect(
GleanMetrics.passwordReset.recoveryKeySubmit as jest.Mock
).toHaveBeenCalledTimes(1);
});
});
it('on error and lost recovery key click', async () => {
const accountWithInvalidKey = {
resetPasswordStatus: jest.fn().mockResolvedValue(true),
getRecoveryKeyBundle: jest.fn().mockRejectedValue(new Error('Boop')),
} as unknown as Account;
renderSubject({ account: accountWithInvalidKey });
const submitButton = await screen.findByRole('button', {
name: 'Confirm account recovery key',
});
await typeByLabelText('Enter account recovery key')('zzz');
fireEvent.click(submitButton);
await screen.findByText('Invalid account recovery key');
expect(logViewEvent).toHaveBeenCalledWith(
'flow',
`${viewName}.fail`,
REACT_ENTRYPOINT
);
fireEvent.click(
screen.getByRole('link', {
name: 'Dont have an account recovery key?',
})
);
expect(logViewEvent).toHaveBeenCalledWith(
'flow',
`lost-recovery-key.${viewName}`,
REACT_ENTRYPOINT
);
});
});
});

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

@ -1,322 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Link, useLocation } from '@reach/router';
import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery';
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { logPageViewEvent, logViewEvent } from '../../../lib/metrics';
import GleanMetrics from '../../../lib/glean';
import { useAccount, useSensitiveDataClient } from '../../../models';
import { FtlMsg } from 'fxa-react/lib/utils';
import { useFtlMsgResolver } from '../../../models/hooks';
import { InputText } from '../../../components/InputText';
import CardHeader from '../../../components/CardHeader';
import WarningMessage from '../../../components/WarningMessage';
import { REACT_ENTRYPOINT } from '../../../constants';
import AppLayout from '../../../components/AppLayout';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import base32Decode from 'base32-decode';
import { decryptRecoveryKeyData } from 'fxa-auth-client/lib/recoveryKey';
import { isBase32Crockford } from '../../../lib/utilities';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import Banner, { BannerType } from '../../../components/Banner';
import {
AccountRecoveryConfirmKeyFormData,
AccountRecoveryConfirmKeyProps,
AccountRecoveryConfirmKeySubmitData,
} from './interfaces';
import { LinkStatus } from '../../../lib/types';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
export const viewName = 'account-recovery-confirm-key';
const AccountRecoveryConfirmKey = ({
linkModel,
setLinkStatus,
integration,
}: AccountRecoveryConfirmKeyProps) => {
const serviceName = integration.getServiceName();
const [tooltipText, setTooltipText] = useState<string>('');
const [bannerMessage, setBannerMessage] = useState<string | ReactElement>();
// The password forgot code can only be used once to retrieve `accountResetToken`
// so we set its value after the first request for subsequent requests.
const [fetchedResetToken, setFetchedResetToken] = useState('');
const [isFocused, setIsFocused] = useState(false);
// Show loading spinner until token is valid, else LinkValidator handles invalid states
const [showLoadingSpinner, setShowLoadingSpinner] = useState(true);
// We use this to debounce the submit button
const [isLoading, setIsLoading] = useState(false);
const account = useAccount();
const ftlMsgResolver = useFtlMsgResolver();
const location = useLocation();
const navigate = useNavigate();
const sensitiveDataClient = useSensitiveDataClient();
useEffect(() => {
const checkPasswordForgotToken = async (token: string) => {
try {
const isValid = await account.resetPasswordStatus(token);
if (isValid) {
setLinkStatus(LinkStatus.valid);
setShowLoadingSpinner(false);
logPageViewEvent(viewName, REACT_ENTRYPOINT);
GleanMetrics.passwordReset.recoveryKeyView();
} else {
setLinkStatus(LinkStatus.expired);
}
} catch (e) {
setLinkStatus(LinkStatus.damaged);
}
};
checkPasswordForgotToken(linkModel.token);
}, [account, linkModel.token, setLinkStatus]);
const { handleSubmit, register, formState } =
useForm<AccountRecoveryConfirmKeyFormData>({
mode: 'onChange',
criteriaMode: 'all',
defaultValues: {
recoveryKey: '',
},
});
const onFocus = () => {
if (!isFocused) {
logViewEvent('flow', `${viewName}.engage`, REACT_ENTRYPOINT);
setIsFocused(true);
}
};
const getRecoveryBundleAndNavigate = useCallback(
async ({
accountResetToken,
recoveryKey,
uid,
email,
}: {
accountResetToken: string;
recoveryKey: string;
uid: string;
email: string;
}) => {
const { recoveryData, recoveryKeyId } =
await account.getRecoveryKeyBundle(accountResetToken, recoveryKey, uid);
logViewEvent('flow', `${viewName}.success`, REACT_ENTRYPOINT);
const decodedRecoveryKey = base32Decode(recoveryKey, 'Crockford');
const uint8RecoveryKey = new Uint8Array(decodedRecoveryKey);
const { kB } = await decryptRecoveryKeyData(
uint8RecoveryKey,
recoveryData,
uid
);
sensitiveDataClient.setData('reset', { kB });
navigate('/account_recovery_reset_password', {
state: {
accountResetToken,
recoveryKeyId,
},
});
},
[account, navigate, sensitiveDataClient]
);
const checkRecoveryKey = useCallback(
async ({
recoveryKey,
token,
code,
email,
uid,
}: AccountRecoveryConfirmKeySubmitData) => {
try {
let resetToken = fetchedResetToken;
if (!resetToken) {
const accountResetToken = await account.passwordForgotVerifyCode(
token,
code,
true
);
setFetchedResetToken(accountResetToken);
resetToken = accountResetToken;
}
await getRecoveryBundleAndNavigate({
accountResetToken: resetToken,
recoveryKey,
uid,
email,
});
} catch (error) {
setIsLoading(false);
logViewEvent('flow', `${viewName}.fail`, REACT_ENTRYPOINT);
// if the link expired or the reset was completed in another tab/browser
// between page load and form submission
// on form submission, redirect to link expired page to provide a path to resend a link
if (error.errno === AuthUiErrors.INVALID_TOKEN.errno) {
setLinkStatus(LinkStatus.expired);
} else {
// NOTE: in content-server, we only check for invalid token and invalid recovery
// key, and note that all other errors are unexpected. However in practice,
// users could also trigger (for example) an 'invalid hex string: null' message or throttling errors.
// Here, we are using the auth errors library, and unaccounted errors are announced as unexpected.
const localizedBannerMessage = getLocalizedErrorMessage(
ftlMsgResolver,
error
);
if (error.errno === AuthUiErrors.INVALID_RECOVERY_KEY.errno) {
setTooltipText(localizedBannerMessage);
} else {
setBannerMessage(localizedBannerMessage);
}
}
}
},
[
account,
fetchedResetToken,
ftlMsgResolver,
getRecoveryBundleAndNavigate,
setLinkStatus,
setIsLoading,
]
);
const onSubmit = (submitData: AccountRecoveryConfirmKeySubmitData) => {
const { recoveryKey } = submitData;
setIsLoading(true);
setBannerMessage(undefined);
logViewEvent('flow', `${viewName}.submit`, REACT_ENTRYPOINT);
GleanMetrics.passwordReset.recoveryKeySubmit();
// if the submitted key does not match the expected format,
// abort before submitting to the auth server
if (recoveryKey.length !== 32 || !isBase32Crockford(recoveryKey)) {
const localizedErrorMessage = ftlMsgResolver.getMsg(
'auth-error-159',
'Invalid account recovery key'
);
setTooltipText(localizedErrorMessage);
setIsLoading(false);
logViewEvent('flow', `${viewName}.fail`, REACT_ENTRYPOINT);
} else {
checkRecoveryKey(submitData);
}
};
if (showLoadingSpinner) {
return <LoadingSpinner fullScreen />;
}
return (
<AppLayout>
<CardHeader
headingWithDefaultServiceFtlId="account-recovery-confirm-key-heading-w-default-service"
headingWithCustomServiceFtlId="account-recovery-confirm-key-heading-w-custom-service"
headingText="Reset password with account recovery key"
{...{ serviceName }}
/>
{bannerMessage && (
<Banner type={BannerType.error}>{bannerMessage}</Banner>
)}
<FtlMsg id="account-recovery-confirm-key-instructions-2">
<p className="mt-4 text-sm">
Please enter the one time use account recovery key you stored in a
safe place to regain access to your Mozilla account.
</p>
</FtlMsg>
<WarningMessage
warningMessageFtlId="account-recovery-confirm-key-warning-message"
warningType="Note:"
>
If you reset your password and dont have account recovery key saved,
some of your data will be erased (including synced server data like
history and bookmarks).
</WarningMessage>
<form
noValidate
className="flex flex-col gap-4"
onSubmit={handleSubmit(({ recoveryKey }) => {
// When users create their recovery key, the copyable output has spaces and we
// display it visually this way to users as well for easier reading. Strip that
// from here for less copy-and-paste friction for users.
const recoveryKeyStripped = recoveryKey.replace(/\s/g, '');
onSubmit({
recoveryKey: recoveryKeyStripped,
token: linkModel.token,
code: linkModel.code,
email: linkModel.email,
uid: linkModel.uid,
});
})}
data-testid="account-recovery-confirm-key-form"
>
<FtlMsg id="account-recovery-confirm-key-input" attrs={{ label: true }}>
<InputText
type="text"
label="Enter account recovery key"
name="recoveryKey"
errorText={tooltipText}
onFocusCb={onFocus}
autoFocus
// Crockford base32 encoding is case insensitive, so visually display as
// uppercase here but don't bother transforming the submit data to match
inputOnlyClassName="font-mono uppercase"
className="text-start"
anchorPosition="start"
autoComplete="off"
spellCheck={false}
onChange={() => {
setTooltipText('');
}}
prefixDataTestId="account-recovery-confirm-key"
inputRef={register({ required: true })}
/>
</FtlMsg>
<FtlMsg id="account-recovery-confirm-key-button">
<button
type="submit"
className="cta-primary cta-xl mb-6"
disabled={
isLoading || !formState.isDirty || !!formState.errors.recoveryKey
}
>
Confirm account recovery key
</button>
</FtlMsg>
</form>
<FtlMsg id="account-recovery-lost-recovery-key-link">
<Link
to={`/complete_reset_password${location.search}`}
className="link-blue text-sm"
id="lost-recovery-key"
state={{
lostRecoveryKey: true,
accountResetToken: fetchedResetToken,
}}
onClick={() => {
logViewEvent(
'flow',
`lost-recovery-key.${viewName}`,
REACT_ENTRYPOINT
);
}}
>
Dont have an account recovery key?
</Link>
</FtlMsg>
</AppLayout>
);
};
export default AccountRecoveryConfirmKey;

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

@ -1,33 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { LinkStatus } from '../../../lib/types';
import { BaseIntegration, IntegrationType } from '../../../models';
import { CompleteResetPasswordLink } from '../../../models/reset-password/verification';
export interface AccountRecoveryConfirmKeyFormData {
recoveryKey: string;
}
interface RequiredParamsAccountRecoveryConfirmKey {
email: string;
token: string;
code: string;
uid: string;
}
export type AccountRecoveryConfirmKeySubmitData = {
recoveryKey: string;
} & RequiredParamsAccountRecoveryConfirmKey;
export interface AccountRecoveryConfirmKeyProps {
linkModel: CompleteResetPasswordLink;
setLinkStatus: React.Dispatch<React.SetStateAction<LinkStatus>>;
integration: AccountRecoveryConfirmKeyBaseIntegration;
}
export interface AccountRecoveryConfirmKeyBaseIntegration {
type: IntegrationType;
getServiceName: () => ReturnType<BaseIntegration['getServiceName']>;
}

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

@ -1,121 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { MozServices } from '../../../lib/types';
import { Account, IntegrationType } from '../../../models';
import {
mockAppContext,
MOCK_ACCOUNT,
createHistoryWithQuery,
createAppContext,
mockUrlQueryData,
} from '../../../models/mocks';
import { LinkType } from 'fxa-settings/src/lib/types';
import LinkValidator from '../../../components/LinkValidator';
import { CompleteResetPasswordLink } from '../../../models/reset-password/verification';
import AccountRecoveryConfirmKey from '.';
import { MOCK_SERVICE } from '../../mocks';
import { AccountRecoveryConfirmKeyBaseIntegration } from './interfaces';
// Extend base mocks
export * from '../../mocks';
export const MOCK_SERVICE_NAME = MozServices.FirefoxSync;
export const MOCK_RECOVERY_KEY = 'ARJDF300TFEPRJ7SFYB8QVNVYT60WWS2';
export const MOCK_RESET_TOKEN = 'mockResetToken';
export const MOCK_RECOVERY_KEY_ID = 'mockRecoveryKeyId';
// TODO: combine a lot of mocks with AccountRecoveryResetPassword
const fxDesktopV3ContextParam = { context: 'fx_desktop_v3' };
export const mockCompleteResetPasswordParams = {
email: MOCK_ACCOUNT.primaryEmail.email,
emailToHashWith: MOCK_ACCOUNT.primaryEmail.email,
token: '1111111111111111111111111111111111111111111111111111111111111111',
code: '11111111111111111111111111111111',
uid: MOCK_ACCOUNT.uid,
};
export const paramsWithSyncDesktop = {
...mockCompleteResetPasswordParams,
...fxDesktopV3ContextParam,
};
export const paramsWithMissingEmail = {
...mockCompleteResetPasswordParams,
email: '',
};
export const paramsWithMissingCode = {
...mockCompleteResetPasswordParams,
code: '',
};
export const paramsWithMissingEmailToHashWith = {
...mockCompleteResetPasswordParams,
emailToHashWith: '',
};
export const paramsWithMissingToken = {
...mockCompleteResetPasswordParams,
token: '',
};
export function createMockWebIntegration(): AccountRecoveryConfirmKeyBaseIntegration {
return {
type: IntegrationType.Web,
getServiceName: () => MozServices.Default,
};
}
export function createMockOAuthIntegration(
serviceName = MOCK_SERVICE
): AccountRecoveryConfirmKeyBaseIntegration {
return {
type: IntegrationType.OAuth,
getServiceName: () => serviceName,
};
}
const route = '/account_recovery_confirm_key';
export const getSubject = (
account: Account,
params: Record<string, string>,
integration: AccountRecoveryConfirmKeyBaseIntegration
) => {
const urlQueryData = mockUrlQueryData(params);
const history = createHistoryWithQuery(
route,
new URLSearchParams(params).toString()
);
return {
Subject: () => (
<LinkValidator
linkType={LinkType['reset-password']}
viewName="account-recovery-confirm-key"
createLinkModel={() => {
return new CompleteResetPasswordLink(urlQueryData);
}}
{...{ integration }}
>
{({ setLinkStatus, linkModel }) => (
<AccountRecoveryConfirmKey
{...{ setLinkStatus, linkModel, integration }}
/>
)}
</LinkValidator>
),
route,
history,
appCtx: {
...mockAppContext({
...createAppContext(),
account,
}),
},
};
};

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

@ -1,17 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { RouteComponentProps } from '@reach/router';
import { Integration } from '../../../models';
import AccountRecoveryResetPassword from '.';
const AccountRecoveryResetPasswordContainer = ({
integration,
}: {
integration: Integration;
} & RouteComponentProps) => {
return <AccountRecoveryResetPassword {...{ integration }} />;
};
export default AccountRecoveryResetPasswordContainer;

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

@ -1,10 +0,0 @@
## Account recovery reset password page
# Header for form to create new password
create-new-password-header = Create new password
account-restored-success-message = You have successfully restored your account using your account recovery key. Create a new password to secure your data, and store it in a safe location.
# Feedback displayed in alert bar when password reset is successful
account-recovery-reset-password-success-alert = Password set
# An error case was hit that we cannot account for.
account-recovery-reset-password-unexpected-error = Unexpected error encountered
account-recovery-reset-password-redirecting = Redirecting

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

@ -1,94 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import AccountRecoveryResetPassword from '.';
import {
LocationProvider,
History,
createMemorySource,
createHistory,
} from '@reach/router';
import { Meta } from '@storybook/react';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { AppContext, AppContextValue } from '../../../models';
import { createMockSyncDesktopV3Integration, mockAccount } from './mocks';
import { mockAppContext } from '../../../models/mocks';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
export default {
title: 'Pages/ResetPassword/AccountRecoveryResetPassword',
component: AccountRecoveryResetPassword,
decorators: [withLocalization],
} as Meta;
const baseUrl = `http://${window.location.host}/?`;
const storyWithProps = (ctx: AppContextValue, history?: History) => {
return (
<AppContext.Provider value={ctx}>
<LocationProvider {...{ history }}>
<AccountRecoveryResetPassword
integration={createMockSyncDesktopV3Integration()}
/>
</LocationProvider>
</AppContext.Provider>
);
};
function setup() {
const account = mockAccount();
const source = createMemorySource('/reset_password');
const history = createHistory(source);
// Create the default url state
const href = `${baseUrl}`;
history.location.href = href;
history.location.search =
'email=foo%40email.com&emailToHashWith=foo%40email.com&code=123&token=1234&uid=1234';
history.location.state = {
kB: '1234',
accountResetToken: '1234',
recoveryKeyId: '1234',
};
const ctx = mockAppContext({
account,
});
return {
ctx,
history,
};
}
export const WithValidLink = () => {
const { ctx, history } = setup();
return storyWithProps(ctx, history);
};
export const OnSubmitLinkExpired = () => {
const { ctx, history } = setup();
// Mock the response. An INVALID_TOKEN means the link expired.
ctx.account!.resetPasswordWithRecoveryKey = async () => {
const err = AuthUiErrors['INVALID_TOKEN'];
throw err;
};
return storyWithProps(ctx, history);
};
// An invalid link should result in a damaged link error.
export const WithDamagedLink = () => {
const { ctx, history } = setup();
// An email must have an @ symbol.
history.location.search = 'email=foo';
return storyWithProps(ctx, history);
};
export const WithBrokenRecoveryKeyState = () => {
const { ctx, history } = setup();
// An empty kb value should indicate a bad recovery key state.
history.location.state.kB = '';
return storyWithProps(ctx, history);
};

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

@ -1,362 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import AccountRecoveryResetPassword, { viewName } from '.';
import { REACT_ENTRYPOINT } from '../../../constants';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import {
logErrorEvent,
logViewEvent,
usePageViewEvent,
} from '../../../lib/metrics';
import { IntegrationType } from '../../../models';
import {
createAppContext,
createHistoryWithQuery,
mockAppContext,
renderWithRouter,
} from '../../../models/mocks';
import firefox from '../../../lib/channels/firefox';
import {
MOCK_LOCATION_STATE,
MOCK_RESET_DATA,
MOCK_SEARCH_PARAMS,
MOCK_VERIFICATION_INFO,
createMockAccountRecoveryResetPasswordOAuthIntegration,
createMockSyncDesktopV3Integration,
mockAccount,
} from './mocks';
import { AccountRecoveryResetPasswordBaseIntegration } from './interfaces';
import GleanMetrics from '../../../lib/glean';
jest.mock('../../../lib/glean', () => ({
__esModule: true,
default: {
passwordReset: {
recoveryKeyCreatePasswordView: jest.fn(),
recoveryKeyCreatePasswordSubmit: jest.fn(),
},
},
}));
const mockUseNavigateWithoutRerender = jest.fn();
jest.mock('../../../lib/hooks/useNavigateWithoutRerender', () => ({
__esModule: true,
default: () => mockUseNavigateWithoutRerender,
}));
// TODO: better mocking here. LinkValidator sends `params` into page components and
// we mock those params sent to page components... we want to do these validation
// checks in the container component instead.
let mockVerificationInfo = MOCK_VERIFICATION_INFO;
jest.mock('../../../models/verification', () => ({
...jest.requireActual('../../../models/verification'),
CreateVerificationInfo: jest.fn(() => mockVerificationInfo),
}));
let mockSearchParams = MOCK_SEARCH_PARAMS;
let mockLocationState = MOCK_LOCATION_STATE;
const mockLocation = () => {
return {
pathname: '/account_recovery_reset_password',
search: '?' + new URLSearchParams(mockSearchParams),
state: mockLocationState,
};
};
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => {
return {
__esModule: true,
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
useLocation: () => mockLocation(),
};
});
jest.mock('../../../lib/metrics', () => {
return {
usePageViewEvent: jest.fn(),
logViewEvent: jest.fn(),
logErrorEvent: jest.fn(),
setUserPreference: jest.fn(),
};
});
const mockGetData = jest.fn().mockReturnValue({
kB: 'someKb',
});
jest.mock('../../../models', () => {
return {
...jest.requireActual('../../../models'),
useSensitiveDataClient: () => {
return {
getData: mockGetData,
setData: jest.fn(),
};
},
};
});
const route = '/reset_password';
const render = (ui = <Subject />, account = mockAccount()) => {
const history = createHistoryWithQuery(route);
return renderWithRouter(
ui,
{
route,
history,
},
mockAppContext({
...createAppContext(),
account,
})
);
};
const Subject = ({ integration = createMockSyncDesktopV3Integration() }) => (
<AccountRecoveryResetPassword {...{ integration }} />
);
describe('AccountRecoveryResetPassword page', () => {
let account = mockAccount();
beforeEach(() => {
mockLocationState = { ...MOCK_LOCATION_STATE };
mockSearchParams = { ...MOCK_SEARCH_PARAMS };
mockVerificationInfo = { ...MOCK_VERIFICATION_INFO };
account = mockAccount();
});
async function clickResetPassword() {
const button = await screen.findByRole('button', {
name: 'Reset password',
});
await act(async () => {
button.click();
});
}
async function clickReceiveNewLink() {
const button = await screen.findByRole('button', {
name: 'Receive new link',
});
await act(async () => {
button.click();
});
}
async function enterPassword(password: string, password2?: string) {
const newPassword = await screen.findByLabelText('New password');
const newPassword2 = await screen.findByLabelText('Re-enter password');
fireEvent.change(newPassword, { target: { value: password } });
fireEvent.change(newPassword2, {
target: { value: password2 || password },
});
}
describe('damaged link', () => {
describe('required location state recovery key info', () => {
it('requires kB', async () => {
mockGetData.mockReturnValue(undefined);
render();
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
mockGetData.mockReturnValue({ kB: 'someKb' });
});
it('requires recoveryKeyId', async () => {
mockLocationState.recoveryKeyId = '';
render();
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
});
it('requires accountResetToken', async () => {
mockLocationState.accountResetToken = '';
render();
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
});
});
it('shows damaged link message with bad param state', async () => {
// By setting an invalid email state, we trigger a damaged link state.
mockVerificationInfo.email = '';
render(
<Subject
integration={createMockAccountRecoveryResetPasswordOAuthIntegration()}
/>
);
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
});
});
describe('valid link', () => {
beforeEach(() => {
render();
});
it('has valid l10n', () => {
// TODO
// testAllL10n(screen, bundle);
});
it('renders with valid link', async () => {
const heading = await screen.findByRole('heading', {
name: 'Create new password',
});
expect(screen.getByLabelText('New password')).toBeVisible();
expect(screen.getByLabelText('Re-enter password')).toBeVisible();
expect(
screen.getByRole('button', { name: 'Reset password' })
).toBeVisible();
expect(screen.getByText('Remember your password?')).toBeVisible();
expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible();
expect(heading).toBeDefined();
});
it('emits event', () => {
expect(usePageViewEvent).toHaveBeenCalled();
expect(usePageViewEvent).toHaveBeenCalledWith(
'account-recovery-reset-password',
REACT_ENTRYPOINT
);
expect(
GleanMetrics.passwordReset.recoveryKeyCreatePasswordView
).toHaveBeenCalled();
});
it('displays password requirements when the new password field is in focus', async () => {
const newPasswordField = await screen.findByTestId(
'new-password-input-field'
);
expect(
screen.queryByText('Password requirements')
).not.toBeInTheDocument();
fireEvent.focus(newPasswordField);
await waitFor(() => {
expect(screen.getByText('Password requirements')).toBeVisible();
});
});
});
describe('successful reset', () => {
beforeEach(async () => {
account.resetPasswordWithRecoveryKey = jest
.fn()
.mockResolvedValue(MOCK_RESET_DATA);
account.hasTotpAuthClient = jest.fn().mockResolvedValue(false);
render(<Subject />, account);
await enterPassword('foo12356789!');
await clickResetPassword();
});
it('emits a metric on successful reset', async () => {
expect(logViewEvent).toHaveBeenCalledWith(
viewName,
'verification.success'
);
expect(
GleanMetrics.passwordReset.recoveryKeyCreatePasswordSubmit
).toHaveBeenCalled();
});
it('calls account API methods', () => {
// Check that resetPasswordWithRecoveryKey was the first function called
// because it retrieves the session token required by other calls
expect(
(account.resetPasswordWithRecoveryKey as jest.Mock).mock.calls[0]
).toBeTruthy();
});
it('sets integration state', () => {
expect(logViewEvent).toHaveBeenCalledWith(
viewName,
'verification.success'
);
});
});
describe('successful reset, Sync integrations set data and call fxaLoginSignedInUser', () => {
let fxaLoginSignedInUserSpy: jest.SpyInstance;
beforeEach(async () => {
account.resetPasswordWithRecoveryKey = jest
.fn()
.mockResolvedValue(MOCK_RESET_DATA);
account.hasTotpAuthClient = jest.fn().mockResolvedValue(false);
fxaLoginSignedInUserSpy = jest.spyOn(firefox, 'fxaLoginSignedInUser');
});
const testSyncIntegration = async (
integration: AccountRecoveryResetPasswordBaseIntegration
) => {
render(<Subject {...{ integration }} />, account);
await enterPassword('foo12356789!');
await clickResetPassword();
};
it('Desktop v3', async () => {
const integration = createMockSyncDesktopV3Integration();
await testSyncIntegration(integration);
expect(integration.data.resetPasswordConfirm).toBeTruthy();
expect(fxaLoginSignedInUserSpy).toHaveBeenCalled();
});
it('OAuth Sync', async () => {
const integration =
createMockAccountRecoveryResetPasswordOAuthIntegration(undefined, true);
await testSyncIntegration(integration);
expect(integration.data.resetPasswordConfirm).toBeTruthy();
expect(fxaLoginSignedInUserSpy).toHaveBeenCalled();
});
});
it('navigates as expected without totp', async () => {
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
`/reset_password_with_recovery_key_verified`
);
});
});
describe('expired link', () => {
beforeEach(async () => {
// A link is deemed expired if the server returns an invalid token error.
account.resetPasswordWithRecoveryKey = jest
.fn()
.mockRejectedValue(AuthUiErrors['INVALID_TOKEN']);
account.resetPassword = jest.fn();
render(<Subject />, account);
await enterPassword('foo12356789!');
await clickResetPassword();
});
it('logs error event', async () => {
expect(logErrorEvent).toBeCalled();
expect(true).toBeTruthy();
});
it('renders LinkExpired component', async () => {
await clickReceiveNewLink();
await screen.findByRole('heading', {
name: 'Reset password link expired',
});
});
});
});

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

@ -1,267 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useState } from 'react';
import { useLocation } from '@reach/router';
import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery';
import { FtlMsg } from 'fxa-react/lib/utils';
import { useForm } from 'react-hook-form';
import AppLayout from '../../../components/AppLayout';
import Banner, { BannerType } from '../../../components/Banner';
import CardHeader from '../../../components/CardHeader';
import FormPasswordWithBalloons from '../../../components/FormPasswordWithBalloons';
import { ResetPasswordLinkDamaged } from '../../../components/LinkDamaged';
import LinkRememberPassword from '../../../components/LinkRememberPassword';
import { LinkExpiredResetPassword } from '../../../components/LinkExpiredResetPassword';
import { REACT_ENTRYPOINT } from '../../../constants';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import {
logErrorEvent,
logViewEvent,
setUserPreference,
settingsViewName,
usePageViewEvent,
} from '../../../lib/metrics';
import { useAccount, useSensitiveDataClient } from '../../../models';
import { LinkStatus } from '../../../lib/types';
import {
isOAuthIntegration,
isSyncDesktopV3Integration,
isSyncOAuthIntegration,
} from '../../../models';
import {
AccountRecoveryResetPasswordBannerState,
AccountRecoveryResetPasswordFormData,
AccountRecoveryResetPasswordLocationState,
AccountRecoveryResetPasswordProps,
} from './interfaces';
import { CreateVerificationInfo } from '../../../models/verification';
import firefox from '../../../lib/channels/firefox';
import GleanMetrics from '../../../lib/glean';
// This page is based on complete_reset_password but has been separated to align with the routes.
// Users should only see this page if they initiated account recovery with a valid account recovery key
// Account recovery properties must be set to recover the account using the recovery key
// (recoveryKeyId, accountResetToken, kb)
export const viewName = 'account-recovery-reset-password';
const AccountRecoveryResetPassword = ({
integration,
}: AccountRecoveryResetPasswordProps) => {
usePageViewEvent(viewName, REACT_ENTRYPOINT);
GleanMetrics.passwordReset.recoveryKeyCreatePasswordView();
const account = useAccount();
const navigate = useNavigate();
const sensitiveDataClient = useSensitiveDataClient();
const location = useLocation() as ReturnType<typeof useLocation> & {
state: AccountRecoveryResetPasswordLocationState;
};
// TODO: This should be done in a container component
const verificationInfo = CreateVerificationInfo();
const [bannerState, setBannerState] =
useState<AccountRecoveryResetPasswordBannerState>(
AccountRecoveryResetPasswordBannerState.None
);
const sensitiveData = sensitiveDataClient.getData('reset');
const { kB } = (sensitiveData as unknown as { kB: string }) || {};
// TODO: This should be done in a container component
const linkIsValid = !!(
location.state.accountResetToken &&
kB &&
location.state.recoveryKeyId &&
verificationInfo.email
);
const [linkStatus, setLinkStatus] = useState<LinkStatus>(
linkIsValid ? LinkStatus.valid : LinkStatus.damaged
);
const onFocusMetricsEvent = () => {
logViewEvent(settingsViewName, `${viewName}.engage`);
};
const { handleSubmit, register, getValues, errors, formState, trigger } =
useForm<AccountRecoveryResetPasswordFormData>({
mode: 'onTouched',
criteriaMode: 'all',
defaultValues: {
newPassword: '',
confirmPassword: '',
},
});
if (linkStatus === 'damaged') {
return <ResetPasswordLinkDamaged />;
}
if (linkStatus === 'expired') {
if (isOAuthIntegration(integration)) {
const service = integration.getService();
const redirectUri = integration.getRedirectUri();
return (
<LinkExpiredResetPassword
email={verificationInfo.email}
{...{ viewName, service, redirectUri }}
/>
);
}
return (
<LinkExpiredResetPassword
email={verificationInfo.email}
{...{ viewName, integration }}
/>
);
}
return (
<AppLayout>
<CardHeader
headingText="Create new password"
headingTextFtlId="create-new-password-header"
/>
{AccountRecoveryResetPasswordBannerState.Redirecting === bannerState && (
<Banner type={BannerType.info}>
<FtlMsg id="account-recovery-reset-password-redirecting">
<p>Redirecting</p>
</FtlMsg>
</Banner>
)}
{AccountRecoveryResetPasswordBannerState.UnexpectedError ===
bannerState && (
<Banner type={BannerType.error}>
<FtlMsg id="account-recovery-reset-password-unexpected-error">
<p>Unexpected error encountered</p>
</FtlMsg>
</Banner>
)}
{AccountRecoveryResetPasswordBannerState.PasswordResetSuccess ===
bannerState && (
<Banner type={BannerType.success}>
<FtlMsg id="account-recovery-reset-password-success-alert">
<p>Password set</p>
</FtlMsg>
</Banner>
)}
<FtlMsg id="account-restored-success-message">
<p className="text-sm mb-4">
You have successfully restored your account using your account
recovery key. Create a new password to secure your data, and store it
in a safe location.
</p>
</FtlMsg>
{/* Hidden email field is to allow Fx password manager
to correctly save the updated password. Without it,
the password manager tries to save the old password
as the username. */}
<input
type="email"
value={verificationInfo.email}
className="hidden"
readOnly
/>
<section className="text-start mt-4">
<FormPasswordWithBalloons
{...{
formState,
errors,
trigger,
register,
getValues,
}}
passwordFormType="reset"
onSubmit={handleSubmit(
(data: AccountRecoveryResetPasswordFormData) => {
onSubmit(data);
},
(err) => {
console.error(err);
}
)}
email={verificationInfo.email}
loading={false}
onFocusMetricsEvent={onFocusMetricsEvent}
/>
</section>
<LinkRememberPassword email={verificationInfo.email} />
</AppLayout>
);
async function onSubmit(data: AccountRecoveryResetPasswordFormData) {
const password = data.newPassword;
const email = verificationInfo.email;
GleanMetrics.passwordReset.recoveryKeyCreatePasswordSubmit();
try {
const options = {
password,
accountResetToken: location.state.accountResetToken,
kB,
recoveryKeyId: location.state.recoveryKeyId,
emailToHashWith: verificationInfo.emailToHashWith || email,
};
const accountResetData = await account.resetPasswordWithRecoveryKey(
options
);
// TODO: do we need this? Is integration data the right place for it if so?
integration.data.resetPasswordConfirm = true;
logViewEvent(viewName, 'verification.success');
if (
isSyncDesktopV3Integration(integration) ||
isSyncOAuthIntegration(integration)
) {
firefox.fxaLoginSignedInUser({
authAt: accountResetData.authAt,
email,
keyFetchToken: accountResetData.keyFetchToken,
sessionToken: accountResetData.sessionToken,
uid: accountResetData.uid,
unwrapBKey: accountResetData.unwrapBKey,
verified: accountResetData.verified,
});
}
alertSuccess();
navigateAway();
} catch (err) {
if (AuthUiErrors['INVALID_TOKEN'].errno === err.errno) {
logErrorEvent({ viewName, ...err });
setLinkStatus(LinkStatus.expired);
} else {
logErrorEvent(err);
setBannerState(AccountRecoveryResetPasswordBannerState.UnexpectedError);
}
}
}
function alertSuccess() {
setBannerState(
AccountRecoveryResetPasswordBannerState.PasswordResetSuccess
);
}
async function navigateAway() {
setUserPreference('account-recovery', false);
logViewEvent(viewName, 'recovery-key-consume.success');
navigate('/reset_password_with_recovery_key_verified');
}
};
export default AccountRecoveryResetPassword;

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

@ -1,58 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { RouteComponentProps } from '@reach/router';
import {
BaseIntegrationData,
IntegrationType,
OAuthIntegration,
OAuthIntegrationData,
} from '../../../models';
export type AccountRecoveryResetPasswordProps = {
integration: AccountRecoveryResetPasswordIntegration;
} & RouteComponentProps;
export interface AccountRecoveryResetPasswordFormData {
newPassword: string;
confirmPassword: string;
}
export enum AccountRecoveryResetPasswordBannerState {
None,
UnexpectedError,
PasswordResetSuccess,
Redirecting,
PasswordResendError,
ValidationError,
}
export interface AccountRecoveryResetPasswordLocationState {
accountResetToken: string;
kB: string;
recoveryKeyId: string;
}
export interface AccountRecoveryResetPasswordOAuthIntegration {
type: IntegrationType.OAuth;
data: {
uid: OAuthIntegrationData['uid'];
resetPasswordConfirm: OAuthIntegrationData['resetPasswordConfirm'];
};
getService: () => ReturnType<OAuthIntegration['getService']>;
getRedirectUri: () => ReturnType<OAuthIntegration['getService']>;
isSync: () => ReturnType<OAuthIntegration['isSync']>;
}
export interface AccountRecoveryResetPasswordBaseIntegration {
type: IntegrationType;
data: {
uid: BaseIntegrationData['uid'];
resetPasswordConfirm: BaseIntegrationData['resetPasswordConfirm'];
};
}
export type AccountRecoveryResetPasswordIntegration =
| AccountRecoveryResetPasswordOAuthIntegration
| AccountRecoveryResetPasswordBaseIntegration;

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

@ -1,82 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Account, IntegrationType } from '../../../models';
import { MOCK_ACCOUNT } from '../../../models/mocks';
import {
MOCK_EMAIL,
MOCK_REDIRECT_URI,
MOCK_SERVICE,
MOCK_UID,
} from '../../mocks';
import {
AccountRecoveryResetPasswordBaseIntegration,
AccountRecoveryResetPasswordOAuthIntegration,
} from './interfaces';
export const MOCK_SEARCH_PARAMS = {
email: MOCK_EMAIL,
emailToHashWith: 'blabidi@blabidiboo.com',
code: '123abc',
token: '123abc',
uid: MOCK_UID,
};
// TODO: better mocking here. LinkValidator sends `params` into page components and
// we mock those params sent to page components... we want to do these validation
// checks in the container component instead.
export const MOCK_VERIFICATION_INFO = {
email: MOCK_EMAIL,
emailToHashWith: 'blabidi@blabidiboo.com',
};
export const MOCK_LOCATION_STATE = {
kB: '123',
accountResetToken: '123',
recoveryKeyId: '123',
};
export function mockAccount() {
return {
...MOCK_ACCOUNT,
setLastLogin: () => {},
resetPasswordWithRecoveryKey: () => {},
resetPassword: () => {},
isSessionVerified: () => true,
} as unknown as Account;
}
export const MOCK_RESET_DATA = {
authAt: 12345,
keyFetchToken: 'keyFetchToken',
sessionToken: 'sessionToken',
unwrapBKey: 'unwrapBKey',
verified: true,
};
export function createMockAccountRecoveryResetPasswordOAuthIntegration(
serviceName = MOCK_SERVICE,
isSync = false
): AccountRecoveryResetPasswordOAuthIntegration {
return {
type: IntegrationType.OAuth,
data: {
uid: MOCK_UID,
resetPasswordConfirm: false,
},
getRedirectUri: () => MOCK_REDIRECT_URI,
getService: () => serviceName,
isSync: () => isSync,
};
}
export function createMockSyncDesktopV3Integration(): AccountRecoveryResetPasswordBaseIntegration {
return {
type: IntegrationType.SyncDesktopV3,
data: {
uid: MOCK_UID,
resetPasswordConfirm: false,
},
};
}

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

@ -1,44 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { RouteComponentProps } from '@reach/router';
import CompleteResetPassword from '.';
import { Integration } from '../../../models';
import LinkValidator from '../../../components/LinkValidator';
import { LinkType } from '../../../lib/types';
import { CreateCompleteResetPasswordLink } from '../../../models/reset-password/verification/factory';
const CompleteResetPasswordContainer = ({
integration,
}: {
integration: Integration;
} & RouteComponentProps) => {
// TODO: possibly rethink LinkValidator approach as it's a lot of layers with
// the new container approach. We want to handle validation here while still sharing
// logic with other container components and probably rendering CompleteResetPassword
// and other link status components here. FXA-8099
return (
<LinkValidator
path="/complete_reset_password/*"
linkType={LinkType['reset-password']}
viewName="complete-reset-password"
createLinkModel={() => {
return CreateCompleteResetPasswordLink();
}}
{...{ integration }}
>
{({ setLinkStatus, linkModel }) => (
<CompleteResetPassword
{...{
setLinkStatus,
linkModel,
integration,
}}
/>
)}
</LinkValidator>
);
};
export default CompleteResetPasswordContainer;

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

@ -1,87 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { Meta } from '@storybook/react';
import CompleteResetPassword from '.';
import { Account } from '../../../models';
import { withLocalization } from 'fxa-react/lib/storybooks';
import {
Subject,
mockAccountNoRecoveryKey,
mockAccountWithRecoveryKeyStatusError,
mockAccountWithThrottledError,
paramsWithMissingEmail,
} from './mocks';
import {
createAppContext,
mockAppContext,
produceComponent,
} from '../../../models/mocks';
export default {
title: 'Pages/ResetPassword/CompleteResetPassword',
component: CompleteResetPassword,
decorators: [withLocalization],
} as Meta;
type RenderStoryOptions = {
account?: Account;
params?: Record<string, string>;
};
function renderStory(
{ account = mockAccountNoRecoveryKey, params }: RenderStoryOptions = {},
storyName?: string
) {
const story = () =>
produceComponent(
<Subject />,
{},
{
...mockAppContext({
...createAppContext(),
account,
}),
}
);
story.storyName = storyName;
return story();
}
const accountWithFalseyResetPasswordStatus = {
resetPasswordStatus: () => Promise.resolve(false),
} as unknown as Account;
export const NoRecoveryKeySet = () => {
return renderStory(
{},
'Default - no account recovery key set. Users with one set will be redirected to AccountRecoveryConfirmKey'
);
};
export const ErrorCheckingRecoveryKeyStatus = () => {
return renderStory({
account: mockAccountWithRecoveryKeyStatusError,
});
};
export const ThrottledErrorOnSubmit = () => {
return renderStory({
account: mockAccountWithThrottledError,
});
};
export const WithExpiredLink = () => {
return renderStory({
account: accountWithFalseyResetPasswordStatus,
});
};
export const WithDamagedLink = () => {
return renderStory({
account: mockAccountNoRecoveryKey,
params: paramsWithMissingEmail,
});
};

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

@ -1,468 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import GleanMetrics from '../../../lib/glean';
import { Account, Session, IntegrationType } from '../../../models';
import { logPageViewEvent } from '../../../lib/metrics';
import { REACT_ENTRYPOINT } from '../../../constants';
import {
MOCK_RESET_DATA,
mockCompleteResetPasswordParams,
paramsWithMissingCode,
paramsWithMissingEmail,
paramsWithMissingEmailToHashWith,
paramsWithMissingToken,
paramsWithSyncDesktop,
paramsWithSyncOAuth,
Subject,
} from './mocks';
import firefox from '../../../lib/channels/firefox';
import {
createAppContext,
createHistoryWithQuery,
mockAppContext,
mockSession,
renderWithRouter,
} from '../../../models/mocks';
import { MOCK_EMAIL, MOCK_RESET_TOKEN } from '../../mocks';
// import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils';
// import { FluentBundle } from '@fluent/bundle';
const mockUseNavigateWithoutRerender = jest.fn();
jest.mock('../../../lib/hooks/useNavigateWithoutRerender', () => ({
__esModule: true,
default: () => mockUseNavigateWithoutRerender,
}));
const PASSWORD = 'passwordzxcv';
jest.mock('../../../lib/metrics', () => ({
logPageViewEvent: jest.fn(),
logViewEvent: jest.fn(),
}));
jest.mock('../../../lib/glean', () => ({
__esModule: true,
default: {
passwordReset: { createNewView: jest.fn(), createNewSubmit: jest.fn() },
},
}));
let account: Account;
let session: Session;
let lostRecoveryKey: boolean;
let accountResetToken: string | undefined;
const mockNavigate = jest.fn();
const mockSearchParams = {
email: mockCompleteResetPasswordParams.email,
emailToHashWith: mockCompleteResetPasswordParams.emailToHashWith,
token: mockCompleteResetPasswordParams.token,
code: mockCompleteResetPasswordParams.code,
uid: mockCompleteResetPasswordParams.uid,
};
const search = new URLSearchParams(mockSearchParams);
const mockLocation = () => {
return {
pathname: `/account_recovery_reset_password`,
search,
state: {
lostRecoveryKey,
accountResetToken,
},
};
};
jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
useLocation: () => mockLocation(),
}));
const route = '/complete_reset_password';
const render = (ui: any, account?: Account, session?: Session) => {
const history = createHistoryWithQuery(route);
return renderWithRouter(
ui,
{
route,
history,
},
mockAppContext({
...createAppContext(),
...(account && { account }),
...(session && { session }),
})
);
};
describe('CompleteResetPassword 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(() => {
lostRecoveryKey = false;
account = {
resetPasswordStatus: jest.fn().mockResolvedValue(true),
completeResetPassword: jest.fn().mockResolvedValue(MOCK_RESET_DATA),
hasRecoveryKey: jest.fn().mockResolvedValue(false),
hasTotpAuthClient: jest.fn().mockResolvedValue(false),
} as unknown as Account;
session = mockSession(true, false);
(GleanMetrics.passwordReset.createNewView as jest.Mock).mockReset();
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders the component as expected', async () => {
render(<Subject />, account, session);
// testAllL10n(screen, bundle);
await screen.findByRole('heading', {
name: 'Create new password',
});
screen.getByLabelText('New password');
screen.getByLabelText('Re-enter password');
screen.getByRole('button', { name: 'Reset password' });
expect(screen.getByText('Remember your password?')).toBeVisible();
expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible();
});
it('displays password requirements when the new password field is in focus', async () => {
render(<Subject />, account, session);
const newPasswordField = await screen.findByTestId(
'new-password-input-field'
);
expect(screen.queryByText('Password requirements')).not.toBeInTheDocument();
fireEvent.focus(newPasswordField);
await waitFor(() => {
expect(screen.getByText('Password requirements')).toBeVisible();
});
});
it('renders the component as expected when provided with an expired link', async () => {
account = {
...account,
resetPasswordStatus: jest.fn().mockResolvedValue(false),
} as unknown as Account;
render(<Subject />, account, session);
await screen.findByRole('heading', {
name: 'Reset password link expired',
});
screen.getByText('The link you clicked to reset your password is expired.');
screen.getByRole('button', {
name: 'Receive new link',
});
});
describe('renders the component as expected when provided with a damaged link', () => {
let mockConsoleWarn: jest.SpyInstance;
beforeEach(() => {
// We expect that model bindings will warn us about missing / incorrect values.
// We don't want these warnings to effect test output since they are expected, so we
// will mock the function, and make sure it's called.
mockConsoleWarn = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
});
afterEach(() => {
mockConsoleWarn.mockClear();
});
it('with missing token', async () => {
// TODO: figure out param type when looking at LinkValidator
render(<Subject params={paramsWithMissingToken} />, account);
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
screen.getByText(
'The link you clicked was missing characters, and may have been broken by your email client. Copy the address carefully, and try again.'
);
expect(mockConsoleWarn).toBeCalled();
expect(GleanMetrics.passwordReset.createNewView).not.toBeCalled();
});
it('with missing code', async () => {
render(<Subject params={paramsWithMissingCode} />, account);
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
expect(mockConsoleWarn).toBeCalled();
expect(GleanMetrics.passwordReset.createNewView).not.toBeCalled();
});
it('with missing email', async () => {
render(<Subject params={paramsWithMissingEmail} />, account);
await screen.findByRole('heading', {
name: 'Reset password link damaged',
});
expect(mockConsoleWarn).toBeCalled();
expect(GleanMetrics.passwordReset.createNewView).not.toBeCalled();
});
});
// TODO in FXA-7630: check for metrics event when link is expired or damaged
it('emits the expected metrics on render', async () => {
render(<Subject />, account, session);
await screen.findByRole('heading', {
name: 'Create new password',
});
expect(logPageViewEvent).toHaveBeenCalledWith(
'complete-reset-password',
REACT_ENTRYPOINT
);
expect(GleanMetrics.passwordReset.createNewView).toBeCalledTimes(1);
});
describe('errors', () => {
it('displays "problem setting your password" error', async () => {
account = {
hasRecoveryKey: jest.fn().mockResolvedValue(false),
resetPasswordStatus: jest.fn().mockResolvedValue(true),
completeResetPassword: jest
.fn()
.mockRejectedValue(new Error('Request failed')),
} as unknown as Account;
render(<Subject />, account, session);
await waitFor(() => {
fireEvent.input(screen.getByTestId('new-password-input-field'), {
target: { value: PASSWORD },
});
});
fireEvent.input(screen.getByTestId('verify-password-input-field'), {
target: { value: PASSWORD },
});
fireEvent.click(screen.getByText('Reset password'));
await screen.findByText('Unexpected error');
});
it('displays account recovery key check error', async () => {
account = {
resetPasswordStatus: jest.fn().mockResolvedValue(true),
hasRecoveryKey: jest
.fn()
.mockRejectedValue(new Error('Request failed')),
} as unknown as Account;
render(<Subject />, account, session);
await screen.findByText(
'Sorry, there was a problem checking if you have an account recovery key.',
{ exact: false }
);
const useKeyLink = screen.getByRole('link', {
name: 'Reset your password with your account recovery key.',
});
expect(useKeyLink).toHaveAttribute(
'href',
`/account_recovery_confirm_key${search}`
);
});
});
describe('account has recovery key', () => {
const accountWithRecoveryKey = {
resetPasswordStatus: jest.fn().mockResolvedValue(true),
completeResetPassword: jest.fn().mockResolvedValue(MOCK_RESET_DATA),
hasRecoveryKey: jest.fn().mockResolvedValue(true),
} as unknown as Account;
it('redirects as expected', async () => {
lostRecoveryKey = false;
render(<Subject />, accountWithRecoveryKey, session);
screen.getByLabelText('Loading…');
await waitFor(() =>
expect(accountWithRecoveryKey.hasRecoveryKey).toHaveBeenCalledWith(
mockCompleteResetPasswordParams.email
)
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('/account_recovery_confirm_key'),
{
replace: true,
state: { email: mockCompleteResetPasswordParams.email },
}
);
});
});
it('does not check or redirect when state has lostRecoveryKey', async () => {
lostRecoveryKey = true;
render(<Subject />, accountWithRecoveryKey, session);
// If recovery key reported as lost, default CompleteResetPassword page is rendered
await screen.findByRole('heading', {
name: 'Create new password',
});
expect(accountWithRecoveryKey.hasRecoveryKey).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
describe('can submit', () => {
async function enterPasswordAndSubmit() {
await waitFor(() => {
fireEvent.input(screen.getByTestId('new-password-input-field'), {
target: { value: PASSWORD },
});
});
fireEvent.input(screen.getByTestId('verify-password-input-field'), {
target: { value: PASSWORD },
});
await act(async () => {
fireEvent.click(screen.getByText('Reset password'));
});
}
it('calls expected functions', async () => {
render(<Subject />, account, session);
await enterPasswordAndSubmit();
// Check that completeResetPassword was the first function called
// because it retrieves the session token required by other calls
expect(
(account.completeResetPassword as jest.Mock).mock.calls[0]
).toBeTruthy();
expect(GleanMetrics.passwordReset.createNewSubmit).toBeCalledTimes(1);
});
it('submits with emailToHashWith if present', async () => {
render(<Subject />, account, session);
const { token, emailToHashWith, code } = mockCompleteResetPasswordParams;
await enterPasswordAndSubmit();
expect(account.completeResetPassword).toHaveBeenCalledWith(
false,
token,
code,
emailToHashWith,
PASSWORD,
undefined
);
});
it('submits with email if emailToHashWith is missing', async () => {
render(<Subject params={paramsWithMissingEmailToHashWith} />, account);
const { token, email, code } = paramsWithMissingEmailToHashWith;
await enterPasswordAndSubmit();
expect(account.completeResetPassword).toHaveBeenCalledWith(
false,
token,
code,
email,
PASSWORD,
undefined
);
});
it('submits with accountResetToken if available', async () => {
lostRecoveryKey = true;
accountResetToken = MOCK_RESET_TOKEN;
render(<Subject />, account, session);
const { token, emailToHashWith, code } = mockCompleteResetPasswordParams;
await enterPasswordAndSubmit();
expect(account.completeResetPassword).toHaveBeenCalledWith(
false,
token,
code,
emailToHashWith,
PASSWORD,
MOCK_RESET_TOKEN
);
});
describe('Web integration', () => {
// Not needed once this page doesn't use `hardNavigate`
const originalWindow = window.location;
beforeAll(() => {
// @ts-ignore
delete window.location;
window.location = { ...originalWindow, href: '' };
});
beforeEach(() => {
window.location.href = originalWindow.href;
});
afterAll(() => {
window.location = originalWindow;
});
it('navigates to reset_password_verified', async () => {
render(<Subject />, account, session);
await enterPasswordAndSubmit();
expect(mockUseNavigateWithoutRerender).toHaveBeenCalledWith(
`/reset_password_verified?email=${encodeURIComponent(
MOCK_EMAIL
)}&emailToHashWith=${encodeURIComponent(
MOCK_EMAIL
)}&token=1111111111111111111111111111111111111111111111111111111111111111&code=11111111111111111111111111111111&uid=abc123`,
{
replace: true,
}
);
});
});
describe('Sync integrations', () => {
const testSyncIntegration = async (
integrationType: IntegrationType,
params: Record<string, string>
) => {
const fxaLoginSignedInUserSpy = jest.spyOn(
firefox,
'fxaLoginSignedInUser'
);
render(<Subject {...{ integrationType, params }} />, account, session);
await enterPasswordAndSubmit();
expect(fxaLoginSignedInUserSpy).toBeCalled();
};
describe('desktop v3', () => {
it('calls fxaLoginSignedInUser', async () => {
await testSyncIntegration(
IntegrationType.SyncDesktopV3,
paramsWithSyncDesktop
);
});
});
describe('OAuth sync', () => {
it('calls fxaLoginSignedInUser', async () => {
await testSyncIntegration(IntegrationType.OAuth, paramsWithSyncOAuth);
});
});
});
});
});

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

@ -1,334 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useCallback, useState, useEffect, ReactElement } from 'react';
import { Link, useLocation, useNavigate } from '@reach/router';
import { useForm } from 'react-hook-form';
import {
logPageViewEvent,
logViewEvent,
settingsViewName,
} from '../../../lib/metrics';
import {
useAccount,
useFtlMsgResolver,
isSyncDesktopV3Integration,
isSyncOAuthIntegration,
useConfig,
} from '../../../models';
import WarningMessage from '../../../components/WarningMessage';
import LinkRememberPassword from '../../../components/LinkRememberPassword';
import FormPasswordWithBalloons from '../../../components/FormPasswordWithBalloons';
import { REACT_ENTRYPOINT } from '../../../constants';
import CardHeader from '../../../components/CardHeader';
import AppLayout from '../../../components/AppLayout';
import Banner, { BannerType } from '../../../components/Banner';
import { FtlMsg } from 'fxa-react/lib/utils';
import { LinkStatus } from '../../../lib/types';
import useNavigateWithoutRerender from '../../../lib/hooks/useNavigateWithoutRerender';
import firefox from '../../../lib/channels/firefox';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import {
CompleteResetPasswordFormData,
CompleteResetPasswordLocationState,
CompleteResetPasswordProps,
CompleteResetPasswordSubmitData,
} from './interfaces';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import GleanMetrics from '../../../lib/glean';
import { useValidatedQueryParams } from '../../../lib/hooks/useValidate';
import { KeyStretchExperiment } from '../../../models/experiments/key-stretch-experiment';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
// The equivalent complete_reset_password mustache file included account_recovery_reset_password
// For React, we have opted to separate these into two pages to align with the routes.
//
// Users should only see the CompleteResetPassword page on /complete _reset_password if
// - there is no account recovery key for their account
// - there is an account recovery key for their account, but it was reported as lost
//
// If the user has an account recovery key (and it is not reported as lost),
// navigate to /account_recovery_confirm_key
//
// If account recovery was initiated with a key, redirect to account_recovery_reset_password
export const viewName = 'complete-reset-password';
const CompleteResetPassword = ({
linkModel,
setLinkStatus,
integration,
}: CompleteResetPasswordProps) => {
const [bannerMessage, setBannerMessage] = useState<
undefined | string | ReactElement
>();
/* Show a loading spinner until all checks complete. Without this, users with a
* recovery key set or with an expired or damaged link will experience some jank due
* to an immediate redirect or rerender of this page. */
const [showLoadingSpinner, setShowLoadingSpinner] = useState(true);
const navigate = useNavigate();
const navigateWithoutRerender = useNavigateWithoutRerender();
const keyStretchExperiment = useValidatedQueryParams(KeyStretchExperiment);
const config = useConfig();
const account = useAccount();
const location = useLocation() as ReturnType<typeof useLocation> & {
state: CompleteResetPasswordLocationState;
};
const ftlMsgResolver = useFtlMsgResolver();
const { handleSubmit, register, getValues, errors, formState, trigger } =
useForm<CompleteResetPasswordFormData>({
mode: 'onTouched',
criteriaMode: 'all',
defaultValues: {
newPassword: '',
confirmPassword: '',
},
});
const checkForRecoveryKeyAndNavigate = useCallback(
async (email: string) => {
try {
const hasRecoveryKey = await account.hasRecoveryKey(email);
if (hasRecoveryKey) {
navigate(`/account_recovery_confirm_key${location.search}`, {
replace: true,
state: { ...{ email } },
});
}
} catch (error) {
// If checking for an account recovery key fails,
// we provide the user with the option to manually navigate to the account recovery flow
setBannerMessage(
<>
<FtlMsg id="complete-reset-password-recovery-key-error-v2">
<p>
Sorry, there was a problem checking if you have an account
recovery key.
</p>
</FtlMsg>
<FtlMsg id="complete-reset-password-recovery-key-link">
<Link
to={`/account_recovery_confirm_key${location.search}`}
className="link-white underline-offset-4"
>
Reset your password with your account recovery key.
</Link>
</FtlMsg>
</>
);
}
},
[account, location.search, navigate]
);
const handleRecoveryKeyStatus = useCallback(async () => {
if (!location.state?.lostRecoveryKey) {
await checkForRecoveryKeyAndNavigate(linkModel.email);
}
renderCompleteResetPassword();
}, [
checkForRecoveryKeyAndNavigate,
location.state?.lostRecoveryKey,
linkModel.email,
]);
const checkPasswordForgotToken = useCallback(
async (token: string) => {
try {
const isValid = await account.resetPasswordStatus(token);
if (isValid) {
setLinkStatus(LinkStatus.valid);
handleRecoveryKeyStatus();
} else {
setLinkStatus(LinkStatus.expired);
}
} catch (e) {
setLinkStatus(LinkStatus.damaged);
}
},
[account, handleRecoveryKeyStatus, setLinkStatus]
);
const renderCompleteResetPassword = () => {
setShowLoadingSpinner(false);
logPageViewEvent(viewName, REACT_ENTRYPOINT);
GleanMetrics.passwordReset.createNewView();
};
/* When the user clicks the confirm password reset link from their email, we check
* the status of the link. If the link is valid, we check if a recovery key is enabled.
* If there is a recovery key, we navigate to the `account_recovery_confirm_key` page.
* If there isn't, we stay on this page and continue a regular password reset.
* If users clicked the link leading back to this page from `account_recovery_confirm_key`,
* we assume the user has lost the key and pass along a `lostRecoveryKey` flag
* so we don't perform the check and redirect again.
*
* If the link is -not- valid, we render link expired or link damaged.
*
* Additionally, the user can submit an invalid account recovery key and receive back
* an `accountResetToken`, then follow the link back to this page. In this case, we
* should _not_ check if the 'token' parameter is valid, since it will be invalid after
* this token is provided to us.
*/
useEffect(() => {
// If a user comes from AccountRecoveryConfirmKey and attempted a recovery key
// submission, 'token' was already verified and used to fetch the reset token
if (!location.state?.accountResetToken) {
checkPasswordForgotToken(linkModel.token);
} else {
renderCompleteResetPassword();
}
}, [
checkPasswordForgotToken,
location.state?.accountResetToken,
linkModel.token,
]);
const alertSuccessAndNavigate = useCallback(() => {
setBannerMessage('');
navigateWithoutRerender(
`/reset_password_verified${window.location.search}`,
{ replace: true }
);
}, [navigateWithoutRerender]);
const onFocusMetricsEvent = () => {
logViewEvent(settingsViewName, `${viewName}.engage`);
};
const onSubmit = useCallback(
async ({
newPassword,
token,
code,
email,
emailToHashWith,
}: CompleteResetPasswordSubmitData) => {
try {
// The `emailToHashWith` option is returned by the auth-server to let the front-end
// know what to hash the new password with. This is important in the scenario where a user
// has changed their primary email address. In this case, they must still hash with the
// account's original email because this will maintain backwards compatibility with
// how account password hashing works previously.
const emailToUse = emailToHashWith || email;
GleanMetrics.passwordReset.createNewSubmit();
const accountResetData = await account.completeResetPassword(
keyStretchExperiment.queryParamModel.isV2(config),
token,
code,
emailToUse,
newPassword,
location.state?.accountResetToken
);
let isHardNavigate = false;
if (
isSyncDesktopV3Integration(integration) ||
isSyncOAuthIntegration(integration)
) {
firefox.fxaLoginSignedInUser({
authAt: accountResetData.authAt,
email,
keyFetchToken: accountResetData.keyFetchToken,
sessionToken: accountResetData.sessionToken,
uid: accountResetData.uid,
unwrapBKey: accountResetData.unwrapBKey,
verified: accountResetData.verified,
});
}
if (!isHardNavigate) {
alertSuccessAndNavigate();
}
} catch (err) {
// if the link expired or the reset was completed in another tab/browser
// between page load and form submission
// on form submission, redirect to link expired page to provide a path to resend a link
if (err.errno === AuthUiErrors.INVALID_TOKEN.errno) {
setLinkStatus(LinkStatus.expired);
} else {
const localizedBannerMessage = getLocalizedErrorMessage(
ftlMsgResolver,
err
);
setBannerMessage(localizedBannerMessage);
}
}
},
[
account,
integration,
location.state?.accountResetToken,
alertSuccessAndNavigate,
ftlMsgResolver,
setLinkStatus,
keyStretchExperiment,
config,
]
);
if (showLoadingSpinner) {
return <LoadingSpinner fullScreen />;
}
return (
<AppLayout>
<CardHeader
headingText="Create new password"
headingTextFtlId="complete-reset-pw-header"
/>
{bannerMessage && (
<Banner type={BannerType.error}>{bannerMessage}</Banner>
)}
<WarningMessage
warningMessageFtlId="complete-reset-password-warning-message-2"
warningType="Remember:"
>
When you reset your password, you reset your account. You may lose some
of your personal information (including history, bookmarks, and
passwords). Thats because we encrypt your data with your password to
protect your privacy. Youll still keep any subscriptions you may have
and Pocket data will not be affected.
</WarningMessage>
{/* Hidden email field is to allow Fx password manager
to correctly save the updated password. Without it,
the password manager tries to save the old password
as the username. */}
<input type="email" value={linkModel.email} className="hidden" readOnly />
<section className="text-start mt-4">
<FormPasswordWithBalloons
{...{
formState,
errors,
trigger,
register,
getValues,
}}
email={linkModel.email}
passwordFormType="reset"
onSubmit={handleSubmit(({ newPassword }) =>
onSubmit({
newPassword,
token: linkModel.token,
code: linkModel.code,
email: linkModel.email,
emailToHashWith: linkModel.emailToHashWith,
})
)}
loading={false}
onFocusMetricsEvent={onFocusMetricsEvent}
/>
</section>
<LinkRememberPassword email={linkModel.email} />
</AppLayout>
);
};
export default CompleteResetPassword;

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

@ -1,55 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { IntegrationSubsetType } from '../../../lib/integrations';
import { LinkStatus } from '../../../lib/types';
import {
IntegrationType,
OAuthIntegration,
OAuthIntegrationData,
} from '../../../models';
import { CompleteResetPasswordLink } from '../../../models/reset-password/verification';
export enum CompleteResetPasswordErrorType {
'none',
'recovery-key',
'complete-reset',
}
export interface CompleteResetPasswordFormData {
newPassword: string;
confirmPassword: string;
}
export type CompleteResetPasswordSubmitData = {
newPassword: string;
} & CompleteResetPasswordParams;
export interface CompleteResetPasswordLocationState {
lostRecoveryKey: boolean;
accountResetToken: string;
}
export interface CompleteResetPasswordParams {
email: string;
emailToHashWith: string | undefined;
code: string;
token: string;
}
export interface CompleteResetPasswordOAuthIntegration {
type: IntegrationType.OAuth;
data: { uid: OAuthIntegrationData['uid'] };
isSync: () => ReturnType<OAuthIntegration['isSync']>;
}
export type CompleteResetPasswordIntegration =
| CompleteResetPasswordOAuthIntegration
| IntegrationSubsetType;
export interface CompleteResetPasswordProps {
linkModel: CompleteResetPasswordLink;
setLinkStatus: React.Dispatch<React.SetStateAction<LinkStatus>>;
integration: CompleteResetPasswordIntegration;
}

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

@ -1,152 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { LinkType } from 'fxa-settings/src/lib/types';
import CompleteResetPassword from '.';
import { Account, Integration, IntegrationType } from '../../../models';
import { MOCK_ACCOUNT, mockUrlQueryData } from '../../../models/mocks';
import { CompleteResetPasswordLink } from '../../../models/reset-password/verification';
import {
CompleteResetPasswordIntegration,
CompleteResetPasswordOAuthIntegration,
} from './interfaces';
import { MOCK_UID } from '../../mocks';
import LinkValidator from '../../../components/LinkValidator';
import {
createMockSyncDesktopV3Integration,
createMockWebIntegration,
} from '../../../lib/integrations/mocks';
import { Constants } from '../../../lib/constants';
// TODO: combine a lot of mocks with AccountRecoveryResetPassword
const fxDesktopV3ContextParam = { context: 'fx_desktop_v3' };
const oauthSyncContextParam = { context: Constants.OAUTH_WEBCHANNEL_CONTEXT };
export const mockAccountNoRecoveryKey = {
resetPasswordStatus: () => Promise.resolve(true),
hasRecoveryKey: () => Promise.resolve(false),
} as unknown as Account;
export const mockAccountWithRecoveryKeyStatusError = {
resetPasswordStatus: () => Promise.resolve(true),
hasRecoveryKey: () => {
throw new Error('boop');
},
} as unknown as Account;
const throttledErrorObjWithRetryAfter = {
errno: 114,
retryAfter: 500,
retryAfterLocalized: 'in 15 minutes',
};
// Mocked throttled error with retryAfter value
export const mockAccountWithThrottledError = {
resetPasswordStatus: () => Promise.resolve(true),
hasRecoveryKey: () => Promise.resolve(false),
completeResetPassword: () => Promise.reject(throttledErrorObjWithRetryAfter),
} as unknown as Account;
export const mockCompleteResetPasswordParams = {
email: MOCK_ACCOUNT.primaryEmail.email,
emailToHashWith: MOCK_ACCOUNT.primaryEmail.email,
token: '1111111111111111111111111111111111111111111111111111111111111111',
code: '11111111111111111111111111111111',
uid: MOCK_ACCOUNT.uid,
};
export const paramsWithSyncDesktop = {
...mockCompleteResetPasswordParams,
...fxDesktopV3ContextParam,
};
export const paramsWithSyncOAuth = {
...mockCompleteResetPasswordParams,
...oauthSyncContextParam,
};
export const paramsWithMissingEmail = {
...mockCompleteResetPasswordParams,
email: '',
};
export const paramsWithMissingCode = {
...mockCompleteResetPasswordParams,
code: '',
};
export const paramsWithMissingEmailToHashWith = {
...mockCompleteResetPasswordParams,
emailToHashWith: undefined,
};
export const paramsWithMissingToken = {
...mockCompleteResetPasswordParams,
token: '',
};
export const MOCK_RESET_DATA = {
authAt: 12345,
keyFetchToken: 'keyFetchToken',
sessionToken: 'sessionToken',
unwrapBKey: 'unwrapBKey',
verified: true,
};
export const Subject = ({
integrationType = IntegrationType.Web,
params = mockCompleteResetPasswordParams,
}: {
integrationType?: IntegrationType;
params?: Record<string, string | undefined>;
}) => {
const urlQueryData = mockUrlQueryData(params);
let completeResetPasswordIntegration: CompleteResetPasswordIntegration;
switch (integrationType) {
case IntegrationType.OAuth:
completeResetPasswordIntegration =
createMockResetPasswordOAuthIntegration(
params.context === Constants.OAUTH_WEBCHANNEL_CONTEXT
);
break;
case IntegrationType.SyncDesktopV3:
completeResetPasswordIntegration = createMockSyncDesktopV3Integration();
break;
case IntegrationType.Web:
default:
completeResetPasswordIntegration = createMockWebIntegration();
}
return (
<LinkValidator
linkType={LinkType['reset-password']}
viewName={'complete-reset-password'}
createLinkModel={() => {
return new CompleteResetPasswordLink(urlQueryData);
}}
// TODO worth fixing this type?
integration={completeResetPasswordIntegration as Integration}
>
{({ setLinkStatus, linkModel }) => (
<CompleteResetPassword
{...{ setLinkStatus, linkModel }}
integration={completeResetPasswordIntegration}
/>
)}
</LinkValidator>
);
};
function createMockResetPasswordOAuthIntegration(
isSync = false
): CompleteResetPasswordOAuthIntegration {
return {
type: IntegrationType.OAuth,
isSync: () => isSync,
data: {
uid: MOCK_UID,
},
};
}

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

@ -1,9 +0,0 @@
## Confirm Reset Password Component
# Second step of password reset flow for Firefox accounts
# Header confirming that a password reset email has been sent to the user's email address
confirm-pw-reset-header = Reset email sent
# Instructions to continue the password reset process
# { $email } is the email entered by the user and where the password reset instructions were sent
confirm-pw-reset-instructions = Click the link emailed to { $email } within the next hour to create a new password.

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

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

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

@ -1,138 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import ConfirmResetPassword, { viewName } from '.';
// import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils';
// import { FluentBundle } from '@fluent/bundle';
import {
MOCK_PASSWORD_FORGOT_TOKEN,
createMockConfirmResetPasswordOAuthIntegration,
} from './mocks';
import { REACT_ENTRYPOINT } from '../../../constants';
import { Account } from '../../../models';
import {
mockAppContext,
MOCK_ACCOUNT,
createAppContext,
renderWithRouter,
createHistoryWithQuery,
} from '../../../models/mocks';
import { usePageViewEvent, logViewEvent } from '../../../lib/metrics';
import { MOCK_EMAIL, MOCK_REDIRECT_URI, MOCK_SERVICE } from '../../mocks';
import { createMockWebIntegration } from '../../../lib/integrations/mocks';
jest.mock('../../../lib/metrics', () => ({
logViewEvent: jest.fn(),
usePageViewEvent: jest.fn(),
}));
const account = MOCK_ACCOUNT as unknown as Account;
const route = '/confirm_reset_password';
const renderWithHistory = (ui: any, account?: Account) => {
const history = createHistoryWithQuery(route);
return renderWithRouter(
ui,
{
route,
history,
},
mockAppContext({
...createAppContext(),
...(account && { account }),
})
);
};
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
useLocation: () => {
return {
state: {
email: MOCK_EMAIL,
passwordForgotToken: MOCK_PASSWORD_FORGOT_TOKEN,
},
};
},
}));
const ConfirmResetPasswordWithWebIntegration = () => (
<ConfirmResetPassword integration={createMockWebIntegration()} />
);
describe('ConfirmResetPassword page', () => {
// TODO enable l10n testing
// let bundle: FluentBundle;
// beforeAll(async () => {
// bundle = await getFtlBundle('settings');
// });
it('renders as expected', () => {
renderWithHistory(<ConfirmResetPasswordWithWebIntegration />);
// testAllL10n(screen, bundle);
const headingEl = screen.getByRole('heading', { level: 1 });
expect(headingEl).toHaveTextContent('Reset email sent');
const confirmPwResetInstructions = screen.getByText(
`Click the link emailed to ${MOCK_EMAIL} within the next hour to create a new password.`
);
expect(confirmPwResetInstructions).toBeInTheDocument();
const resendEmailButton = screen.getByRole('button', {
name: 'Not in inbox or spam folder? Resend',
});
expect(resendEmailButton).toBeInTheDocument();
});
it('sends a new email when clicking on resend button, with OAuth integration', async () => {
account.resetPassword = jest.fn().mockImplementation(() => {
return {
passwordForgotToken: '123',
};
});
renderWithHistory(
<ConfirmResetPassword
integration={createMockConfirmResetPasswordOAuthIntegration()}
/>,
account
);
const resendEmailButton = await screen.findByRole('button', {
name: 'Not in inbox or spam folder? Resend',
});
expect(resendEmailButton).toBeInTheDocument();
fireEvent.click(resendEmailButton);
await waitFor(() => new Promise((r) => setTimeout(r, 100)));
expect(account.resetPassword).toHaveBeenCalledWith(
MOCK_EMAIL,
MOCK_SERVICE
);
expect(logViewEvent).toHaveBeenCalledWith(
'confirm-reset-password',
'resend',
REACT_ENTRYPOINT
);
});
it('emits the expected metrics on render', async () => {
renderWithHistory(<ConfirmResetPasswordWithWebIntegration />);
expect(usePageViewEvent).toHaveBeenCalledWith(viewName, REACT_ENTRYPOINT);
});
it('renders a "Remember your password?" link', () => {
renderWithHistory(<ConfirmResetPasswordWithWebIntegration />);
expect(screen.getByText('Remember your password?')).toBeVisible();
expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible();
});
});

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

@ -1,133 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useCallback, useState } from 'react';
import { RouteComponentProps, useLocation } from '@reach/router';
import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery';
import { POLLING_INTERVAL_MS, REACT_ENTRYPOINT } from '../../../constants';
import { usePageViewEvent, logViewEvent } from '../../../lib/metrics';
import { ResendStatus } from '../../../lib/types';
import {
isOAuthIntegration,
useAccount,
useFtlMsgResolver,
useInterval,
} from '../../../models';
import AppLayout from '../../../components/AppLayout';
import ConfirmWithLink, {
ConfirmWithLinkPageStrings,
} from '../../../components/ConfirmWithLink';
import LinkRememberPassword from '../../../components/LinkRememberPassword';
import { hardNavigate } from 'fxa-react/lib/utils';
import { setOriginalTabMarker } from '../../../lib/storage-utils';
import {
ConfirmResetPasswordIntegration,
ConfirmResetPasswordLocationState,
} from './interfaces';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
export const viewName = 'confirm-reset-password';
const ConfirmResetPassword = ({
integration,
}: {
integration: ConfirmResetPasswordIntegration;
} & RouteComponentProps) => {
usePageViewEvent(viewName, REACT_ENTRYPOINT);
const ftlMsgResolver = useFtlMsgResolver();
const navigate = useNavigate();
let { state } = useLocation();
if (!state) {
state = {};
}
const { email, passwordForgotToken } =
state as ConfirmResetPasswordLocationState;
const account = useAccount();
const [resendStatus, setResendStatus] = useState<ResendStatus>(
ResendStatus.none
);
const [errorMessage, setErrorMessage] = useState<string>();
const [isPolling, setIsPolling] = useState<number | null>(
POLLING_INTERVAL_MS
);
const [currentPasswordForgotToken, setCurrentPasswordForgotToken] =
useState<string>(passwordForgotToken);
const navigateToPasswordReset = useCallback(() => {
navigate('reset_password', { replace: true });
}, [navigate]);
if (!email || !passwordForgotToken) {
navigateToPasswordReset();
}
useInterval(async () => {
try {
// A bit unconventional but this endpoint will throw an invalid token error
// that represents the password has been reset (or that the token is expired)
const isValid = await account.resetPasswordStatus(
currentPasswordForgotToken
);
if (!isValid) {
hardNavigate('/signin', {}, false);
} else {
// TODO: Not sure about this. It works with the flow... but.
setOriginalTabMarker();
}
} catch (err) {
setIsPolling(null);
}
}, isPolling);
const resendEmailHandler = async () => {
setIsPolling(null);
try {
if (isOAuthIntegration(integration)) {
const result = await account.resetPassword(
email,
integration.getService()
);
setCurrentPasswordForgotToken(result.passwordForgotToken);
} else {
const result = await account.resetPassword(email);
setCurrentPasswordForgotToken(result.passwordForgotToken);
}
setResendStatus(ResendStatus.sent);
logViewEvent(viewName, 'resend', REACT_ENTRYPOINT);
} catch (err) {
const localizedErrorMessage = getLocalizedErrorMessage(
ftlMsgResolver,
err
);
setResendStatus(ResendStatus.error);
setErrorMessage(localizedErrorMessage);
} finally {
setIsPolling(POLLING_INTERVAL_MS);
}
};
const confirmResetPasswordStrings: ConfirmWithLinkPageStrings = {
headingFtlId: 'confirm-pw-reset-header',
headingText: 'Reset email sent',
instructionFtlId: 'confirm-pw-reset-instructions',
instructionText: `Click the link emailed to ${email} within the next hour to create a new password.`,
};
return (
<AppLayout>
<ConfirmWithLink
{...{ email, resendEmailHandler, resendStatus, errorMessage }}
confirmWithLinkPageStrings={confirmResetPasswordStrings}
/>
<LinkRememberPassword {...{ email }} />
</AppLayout>
);
};
export default ConfirmResetPassword;

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

@ -1,21 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { IntegrationSubsetType } from '../../../lib/integrations';
import { IntegrationType, OAuthIntegration } from '../../../models';
export interface ConfirmResetPasswordOAuthIntegration {
type: IntegrationType.OAuth;
getRedirectUri: () => ReturnType<OAuthIntegration['getRedirectUri']>;
getService: () => ReturnType<OAuthIntegration['getService']>;
}
export type ConfirmResetPasswordIntegration =
| ConfirmResetPasswordOAuthIntegration
| IntegrationSubsetType;
export interface ConfirmResetPasswordLocationState {
email: string;
passwordForgotToken: string;
}

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

@ -1,19 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { IntegrationType } from '../../../models';
import { MOCK_REDIRECT_URI, MOCK_SERVICE } from '../../mocks';
import { ConfirmResetPasswordOAuthIntegration } from './interfaces';
export const MOCK_PASSWORD_FORGOT_TOKEN = 'abc';
export function createMockConfirmResetPasswordOAuthIntegration(
serviceName = MOCK_SERVICE
): ConfirmResetPasswordOAuthIntegration {
return {
type: IntegrationType.OAuth,
getRedirectUri: () => MOCK_REDIRECT_URI,
getService: () => serviceName,
};
}

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

@ -1,16 +0,0 @@
## ResetPassword page
# Strings within the <span> elements appear as a subheading.
# If more appropriate in a locale, the string within the <span>, "to continue to account settings" can stand alone as "Continue to account settings"
reset-password-heading-w-default-service = Reset password <span>to continue to account settings</span>
# Strings within the <span> elements appear as a subheading.
# If more appropriate in a locale, the string within the <span>, "to continue to { $serviceName }" can stand alone as "Continue to { $serviceName }"
# { $serviceName } represents a product name (e.g., Mozilla VPN) that will be passed in as a variable
reset-password-heading-w-custom-service = Reset password <span>to continue to { $serviceName }</span>
reset-password-warning-message-2 = <span>Note:</span> When you reset your password, you reset your account. You may lose some of your personal information (including history, bookmarks, and passwords). Thats because we encrypt your data with your password to protect your privacy. Youll still keep any subscriptions you may have and { -product-pocket } data will not be affected.
# Users type their email address in this field to start a password reset
reset-password-password-input =
.label = Email
reset-password-button = Begin reset
# Error message displayed in a tooltip when a user attempts to submit a password reset form without entering an email address
reset-password-email-required-error = Email required

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

@ -1,77 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import ResetPassword from '.';
import { Meta } from '@storybook/react';
import { MozServices } from '../../lib/types';
import { withLocalization } from 'fxa-react/lib/storybooks';
import {
mockAccountWithGenericThrottledError,
createMockResetPasswordOAuthIntegration,
createMockResetPasswordWebIntegration,
mockAccountWithThrottledError,
mockAccountWithUnexpectedError,
} from './mocks';
import { renderStoryWithHistory } from '../../lib/storybook-utils';
import { Account, IntegrationType } from '../../models';
import { MOCK_ACCOUNT } from '../../models/mocks';
import { ResetPasswordProps } from './interfaces';
export default {
title: 'Pages/ResetPassword',
component: ResetPassword,
decorators: [withLocalization],
} as Meta;
type RenderStoryOptions = {
account?: Account;
props?: Partial<ResetPasswordProps>;
queryParams?: string;
integrationType?: IntegrationType;
};
function renderStory({
account,
props,
queryParams,
integrationType = IntegrationType.Web,
}: RenderStoryOptions = {}) {
const integration =
integrationType === IntegrationType.OAuth
? createMockResetPasswordOAuthIntegration()
: createMockResetPasswordWebIntegration();
return renderStoryWithHistory(
<ResetPassword {...props} {...{ integration }} />,
'/reset_password',
account,
queryParams
);
}
export const Default = () => renderStory();
export const HeaderWithServiceName = () =>
renderStory({
integrationType: IntegrationType.OAuth,
queryParams: `service=${MozServices.MozillaVPN}`,
});
export const WithForceAuth = () =>
renderStory({
props: {
prefillEmail: MOCK_ACCOUNT.primaryEmail.email,
forceAuth: true,
},
});
export const WithGenericThrottledErrorOnSubmit = () =>
renderStory({ account: mockAccountWithGenericThrottledError });
export const WithThrottledErrorOnSubmit = () =>
renderStory({ account: mockAccountWithThrottledError });
export const WithUnexpectedErrorOnSubmit = () =>
renderStory({ account: mockAccountWithUnexpectedError });

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

@ -1,355 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
// import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils';
// import { FluentBundle } from '@fluent/bundle';
import GleanMetrics from '../../lib/glean';
import { usePageViewEvent } from '../../lib/metrics';
import ResetPassword, { viewName } from '.';
import { REACT_ENTRYPOINT } from '../../constants';
import { MOCK_ACCOUNT, mockAppContext } from '../../models/mocks';
import { Account } from '../../models';
import { AuthUiErrorNos } from '../../lib/auth-errors/auth-errors';
import { typeByLabelText } from '../../lib/test-utils';
import {
createAppContext,
createHistoryWithQuery,
renderWithRouter,
} from '../../models/mocks';
import { MozServices } from '../../lib/types';
import {
createMockResetPasswordOAuthIntegration,
createMockResetPasswordWebIntegration,
} from './mocks';
import { MOCK_SERVICE } from '../mocks';
const mockLogViewEvent = jest.fn();
const mockLogPageViewEvent = jest.fn();
jest.mock('../../lib/metrics', () => ({
...jest.requireActual('../../lib/metrics'),
usePageViewEvent: jest.fn(),
logViewEvent: jest.fn(),
useMetrics: jest.fn(() => ({
logViewEventOnce: mockLogViewEvent,
logPageViewEventOnce: mockLogPageViewEvent,
})),
}));
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
}));
jest.mock('../../lib/glean', () => ({
__esModule: true,
default: { passwordReset: { view: jest.fn(), submit: jest.fn() } },
}));
const route = '/reset_password';
const render = (ui: any, account?: Account) => {
const history = createHistoryWithQuery(route);
return renderWithRouter(
ui,
{
route,
history,
},
mockAppContext({
...createAppContext(),
...(account && { account }),
})
);
};
const ResetPasswordWithWebIntegration = () => (
<ResetPassword
integration={createMockResetPasswordWebIntegration()}
flowQueryParams={{ flowId: '00ff' }}
/>
);
describe('PageResetPassword', () => {
// 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(() => {
(GleanMetrics.passwordReset.view as jest.Mock).mockClear();
(GleanMetrics.passwordReset.submit as jest.Mock).mockClear();
});
it('renders as expected', async () => {
render(<ResetPasswordWithWebIntegration />);
// testAllL10n(screen, bundle);
const headingEl = await screen.findByRole('heading', { level: 1 });
expect(headingEl).toHaveTextContent(
'Reset password to continue to account settings'
);
expect(screen.getByTestId('warning-message-container')).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Begin reset' })
).toBeInTheDocument();
// when forceEmail is NOT provided as a prop, the optional read-only email should not be rendered
const forcedEmailEl = screen.queryByTestId('reset-password-force-email');
expect(forcedEmailEl).not.toBeInTheDocument();
expect(screen.getByText('Remember your password?')).toBeVisible();
expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible();
});
it('renders a custom service name in the header', async () => {
render(
<ResetPassword
integration={createMockResetPasswordOAuthIntegration(
MozServices.FirefoxSync
)}
/>
);
const headingEl = await screen.findByRole('heading', { level: 1 });
expect(headingEl).toHaveTextContent(
`Reset password to continue to Firefox Sync`
);
});
it('emits a metrics event on render', async () => {
render(<ResetPasswordWithWebIntegration />);
await screen.findByText('Reset password');
expect(usePageViewEvent).toHaveBeenCalledWith(viewName, REACT_ENTRYPOINT);
expect(GleanMetrics.passwordReset.view).toHaveBeenCalledTimes(1);
});
it('submit success with OAuth integration', async () => {
const account = {
resetPassword: jest.fn().mockResolvedValue({
passwordForgotToken: '123',
}),
} as unknown as Account;
render(
<ResetPassword integration={createMockResetPasswordOAuthIntegration()} />,
account
);
await act(async () => {
const resetPasswordInput = await screen.findByTestId(
'reset-password-input-field'
);
fireEvent.input(resetPasswordInput, {
target: { value: MOCK_ACCOUNT.primaryEmail.email },
});
});
await act(async () => {
fireEvent.click(await screen.findByText('Begin reset'));
});
expect(GleanMetrics.passwordReset.submit).toHaveBeenCalledTimes(1);
expect(account.resetPassword).toHaveBeenCalled();
expect(account.resetPassword).toHaveBeenCalledWith(
MOCK_ACCOUNT.primaryEmail.email,
MOCK_SERVICE,
undefined,
{}
);
expect(mockNavigate).toHaveBeenCalledWith('/confirm_reset_password', {
replace: true,
state: {
email: 'johndope@example.com',
passwordForgotToken: '123',
},
});
});
it('submit success with trailing space in email', async () => {
const account = {
resetPassword: jest.fn().mockResolvedValue({
passwordForgotToken: '123',
}),
} as unknown as Account;
render(<ResetPasswordWithWebIntegration />, account);
await waitFor(() =>
fireEvent.input(screen.getByTestId('reset-password-input-field'), {
target: { value: `${MOCK_ACCOUNT.primaryEmail.email} ` },
})
);
fireEvent.click(screen.getByText('Begin reset'));
await waitFor(() => {
expect(account.resetPassword).toHaveBeenCalledWith(
MOCK_ACCOUNT.primaryEmail.email,
undefined,
undefined,
{ flowId: '00ff' }
);
expect(mockNavigate).toHaveBeenCalledWith('/confirm_reset_password', {
replace: true,
state: {
email: 'johndope@example.com',
passwordForgotToken: '123',
},
});
});
});
it('submit success with leading space in email', async () => {
const account = {
resetPassword: jest.fn().mockResolvedValue({
passwordForgotToken: '123',
}),
} as unknown as Account;
render(<ResetPasswordWithWebIntegration />, account);
fireEvent.input(await screen.findByTestId('reset-password-input-field'), {
target: { value: ` ${MOCK_ACCOUNT.primaryEmail.email}` },
});
fireEvent.click(await screen.findByText('Begin reset'));
await waitFor(() => {
expect(account.resetPassword).toHaveBeenCalledWith(
MOCK_ACCOUNT.primaryEmail.email,
undefined,
undefined,
{ flowId: '00ff' }
);
expect(mockNavigate).toHaveBeenCalledWith('/confirm_reset_password', {
replace: true,
state: {
email: 'johndope@example.com',
passwordForgotToken: '123',
},
});
});
});
describe('displays error and does not allow submission', () => {
const account = {
resetPassword: jest.fn().mockResolvedValue({
passwordForgotToken: '123',
}),
} as unknown as Account;
it('with an empty email', async () => {
render(<ResetPasswordWithWebIntegration />, account);
const button = await screen.findByRole('button', { name: 'Begin reset' });
fireEvent.click(button);
await screen.findByText('Email required');
expect(account.resetPassword).not.toHaveBeenCalled();
// clears the error onchange
await typeByLabelText('Email')('a');
expect(screen.queryByText('Email required')).not.toBeInTheDocument();
});
it('with an invalid email', async () => {
render(<ResetPasswordWithWebIntegration />, account);
await typeByLabelText('Email')('foxy@gmail.');
const button = await screen.findByRole('button', { name: 'Begin reset' });
fireEvent.click(button, { name: 'Begin reset' });
await screen.findByText('Valid email required');
expect(account.resetPassword).not.toHaveBeenCalled();
// clears the error onchange
await typeByLabelText('Email')('a');
expect(
screen.queryByText('Valid email required')
).not.toBeInTheDocument();
});
});
it('displays an error when the account is unknown', async () => {
const gqlError: any = AuthUiErrorNos[102]; // Unknown account error
const account = {
resetPassword: jest.fn().mockRejectedValue(gqlError),
} as unknown as Account;
render(ResetPasswordWithWebIntegration, account);
fireEvent.input(screen.getByTestId('reset-password-input-field'), {
target: { value: MOCK_ACCOUNT.primaryEmail.email },
});
fireEvent.click(screen.getByRole('button', { name: 'Begin reset' }));
await screen.findByText('Unknown account');
expect(GleanMetrics.passwordReset.view).toHaveBeenCalledTimes(1);
});
it('displays an error when rate limiting kicks in', async () => {
// mocks an error that contains the required values to localize the message
// does not test if the Account model passes in the correct information
// does not test if the message is localized
// does not test if the lang of localizedRetryAfter matches the lang used for the rest of the string
const gqlThrottledErrorWithRetryAfter: any = {
errno: 114,
message: AuthUiErrorNos[114].message,
retryAfter: 500,
retryAfterLocalized: 'in 15 minutes',
}; // Throttled error
const account = {
resetPassword: jest
.fn()
.mockRejectedValue(gqlThrottledErrorWithRetryAfter),
} as unknown as Account;
render(<ResetPasswordWithWebIntegration />, account);
const input = await screen.findByTestId('reset-password-input-field');
fireEvent.input(input, {
target: { value: MOCK_ACCOUNT.primaryEmail.email },
});
const resetButton = await screen.findByText('Begin reset');
await act(async () => {
resetButton.click();
});
await screen.findByText(
'Youve tried too many times. Please try again in 15 minutes.'
);
expect(GleanMetrics.passwordReset.view).toHaveBeenCalledTimes(1);
});
it('handles unexpected errors on submit', async () => {
const unexpectedError: Error = { name: 'fake', message: 'error' }; // Unknown account error
const account = {
resetPassword: jest.fn().mockRejectedValue(unexpectedError),
} as unknown as Account;
render(<ResetPasswordWithWebIntegration />, account);
fireEvent.input(screen.getByTestId('reset-password-input-field'), {
target: { value: MOCK_ACCOUNT.primaryEmail.email },
});
fireEvent.click(screen.getByText('Begin reset'));
await waitFor(() => screen.findByText('Unexpected error'));
});
});

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

@ -1,258 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { RouteComponentProps } from '@reach/router';
import { useNavigateWithQuery as useNavigate } from '../../lib/hooks/useNavigateWithQuery';
import React, { useCallback, useEffect, useState } from 'react';
import { Control, useForm, useWatch } from 'react-hook-form';
import { REACT_ENTRYPOINT } from '../../constants';
import {
usePageViewEvent,
useMetrics,
queryParamsToMetricsContext,
} from '../../lib/metrics';
import {
isOAuthIntegration,
useAccount,
useFtlMsgResolver,
} from '../../models';
import { FtlMsg } from 'fxa-react/lib/utils';
import AppLayout from '../../components/AppLayout';
import Banner, { BannerType } from '../../components/Banner';
import CardHeader from '../../components/CardHeader';
import { InputText } from '../../components/InputText';
import LinkRememberPassword from '../../components/LinkRememberPassword';
import WarningMessage from '../../components/WarningMessage';
import { isEmailValid } from 'fxa-shared/email/helpers';
import { setOriginalTabMarker } from '../../lib/storage-utils';
import { ResetPasswordFormData, ResetPasswordProps } from './interfaces';
import { ConfirmResetPasswordLocationState } from './ConfirmResetPassword/interfaces';
import GleanMetrics from '../../lib/glean';
import { MetricsContext } from 'fxa-auth-client/browser';
import { getLocalizedErrorMessage } from '../../lib/error-utils';
export const viewName = 'reset-password';
// eslint-disable-next-line no-empty-pattern
const ResetPassword = ({
prefillEmail,
forceAuth,
integration,
flowQueryParams = {},
}: ResetPasswordProps & RouteComponentProps) => {
usePageViewEvent(viewName, REACT_ENTRYPOINT);
const [errorText, setErrorText] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [hasFocused, setHasFocused] = useState<boolean>(false);
const account = useAccount();
const navigate = useNavigate();
const ftlMsgResolver = useFtlMsgResolver();
// NOTE: This was previously part of the persistVerificationData. Let's keep these operations atomic in the new version though.
setOriginalTabMarker();
const serviceName = integration.getServiceName();
useEffect(() => {
GleanMetrics.passwordReset.view();
}, []);
const { control, getValues, handleSubmit, register } =
useForm<ResetPasswordFormData>({
mode: 'onTouched',
criteriaMode: 'all',
// The email field is not pre-filled for the reset_password page,
// but if the user enters an email address, the entered email
// address should be propagated back to the signin page. If
// the user enters no email and instead clicks "Remember password?"
// immediately, the /signin page should have the original email.
// See https://github.com/mozilla/fxa-content-server/issues/5293.
defaultValues: {
email: '',
},
});
// Log a metrics event when a user first engages with the form
const { logViewEventOnce: logEngageEvent } = useMetrics();
const onFocus = useCallback(() => {
if (!hasFocused) {
logEngageEvent(viewName, 'engage', REACT_ENTRYPOINT);
setHasFocused(true);
}
}, [hasFocused, logEngageEvent]);
const navigateToConfirmPwReset = useCallback(
(stateData: ConfirmResetPasswordLocationState) => {
navigate('/confirm_reset_password', {
state: stateData,
replace: true,
});
},
[navigate]
);
const clearError = useCallback(() => {
if (errorText !== '') {
setErrorText('');
setErrorMessage('');
}
}, [errorText, setErrorText]);
const submitEmail = useCallback(
async (email: string, options: { metricsContext: MetricsContext }) => {
try {
clearError();
// This will save the scope and oauth state for later
if (isOAuthIntegration(integration)) {
integration.saveOAuthState();
const result = await account.resetPassword(
email,
integration.getService(),
undefined,
options?.metricsContext
);
navigateToConfirmPwReset({
passwordForgotToken: result.passwordForgotToken,
email,
});
} else {
const result = await account.resetPassword(
email,
undefined,
undefined,
options?.metricsContext
);
navigateToConfirmPwReset({
passwordForgotToken: result.passwordForgotToken,
email,
});
}
} catch (err) {
const errorMessage = getLocalizedErrorMessage(ftlMsgResolver, err);
setErrorMessage(errorMessage);
}
},
[account, clearError, ftlMsgResolver, navigateToConfirmPwReset, integration]
);
const onSubmit = useCallback(async () => {
const sanitizedEmail = getValues('email').trim();
if (!sanitizedEmail) {
setErrorText(
ftlMsgResolver.getMsg(
'reset-password-email-required-error',
'Email required'
)
);
} else if (!isEmailValid(sanitizedEmail)) {
setErrorText(
ftlMsgResolver.getMsg('auth-error-1011', 'Valid email required')
);
} else {
GleanMetrics.passwordReset.submit();
submitEmail(sanitizedEmail, {
metricsContext: queryParamsToMetricsContext(
flowQueryParams as unknown as Record<string, string>
),
});
}
}, [flowQueryParams, ftlMsgResolver, getValues, submitEmail]);
const ControlledLinkRememberPassword = ({
control,
}: {
control: Control<ResetPasswordFormData>;
}) => {
const email: string = useWatch({
control,
name: 'email',
defaultValue: getValues().email,
});
return <LinkRememberPassword {...{ email }} />;
};
return (
<AppLayout>
<CardHeader
headingWithDefaultServiceFtlId="reset-password-heading-w-default-service"
headingWithCustomServiceFtlId="reset-password-heading-w-custom-service"
headingText="Reset password"
{...{ serviceName }}
/>
{errorMessage && (
<Banner type={BannerType.error}>
<p>{errorMessage}</p>
</Banner>
)}
<WarningMessage
warningMessageFtlId="reset-password-warning-message-2"
warningType="Note:"
>
When you reset your password, you reset your account. You may lose some
of your personal information (including history, bookmarks, and
passwords). Thats because we encrypt your data with your password to
protect your privacy. Youll still keep any subscriptions you may have
and Pocket data will not be affected.
</WarningMessage>
<form
noValidate
className="flex flex-col gap-4"
onSubmit={handleSubmit(onSubmit)}
data-testid="reset-password-form"
>
{/* if email is forced, display a read-only email */}
{/* do not provide input field to modify the email */}
{forceAuth && prefillEmail && (
<p
data-testid="reset-password-force-email"
className="text-base break-all"
>
{prefillEmail}
</p>
)}
{/* if email is not forced, display input field */}
{!forceAuth && (
<FtlMsg id="reset-password-password-input" attrs={{ label: true }}>
<InputText
type="email"
label="Email"
name="email"
onChange={clearError}
onFocusCb={onFocus}
autoFocus
errorText={errorText}
className="text-start"
anchorPosition="start"
autoComplete="off"
spellCheck={false}
prefixDataTestId="reset-password"
inputRef={register()}
/>
</FtlMsg>
)}
<FtlMsg id="reset-password-button">
<button
data-testid="reset-password-button"
type="submit"
className="cta-primary cta-xl"
>
Begin reset
</button>
</FtlMsg>
</form>
<ControlledLinkRememberPassword {...{ control }} />
</AppLayout>
);
};
export default ResetPassword;

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

@ -1,40 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { QueryParams } from '../..';
import { MozServices } from '../../lib/types';
import {
BaseIntegration,
IntegrationType,
OAuthIntegration,
} from '../../models';
export interface ResetPasswordOAuthIntegration {
type: IntegrationType.OAuth;
getRedirectUri: () => ReturnType<OAuthIntegration['getRedirectUri']>;
saveOAuthState: () => ReturnType<OAuthIntegration['saveOAuthState']>;
getServiceName: () => ReturnType<OAuthIntegration['getServiceName']>;
getService: () => ReturnType<OAuthIntegration['getService']>;
}
export interface ResetPasswordBaseIntegration {
type: IntegrationType;
getServiceName: () => ReturnType<BaseIntegration['getServiceName']>;
}
type ResetPasswordIntegration =
| ResetPasswordOAuthIntegration
| ResetPasswordBaseIntegration;
export interface ResetPasswordProps {
prefillEmail?: string;
forceAuth?: boolean;
serviceName?: MozServices;
integration: ResetPasswordIntegration;
flowQueryParams?: QueryParams;
}
export interface ResetPasswordFormData {
email: string;
}

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

@ -1,60 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Account, IntegrationType } from '../../models';
import {
ResetPasswordBaseIntegration,
ResetPasswordOAuthIntegration,
} from './interfaces';
import { MozServices } from '../../lib/types';
import { MOCK_REDIRECT_URI, MOCK_SERVICE } from '../mocks';
// No error message on submit
export const mockDefaultAccount = {
resetPassword: () =>
Promise.resolve({ passwordForgotToken: 'mockPasswordForgotToken' }),
} as any as Account;
const throttledErrorObjWithRetryAfter = {
errno: 114,
retryAfter: 500,
retryAfterLocalized: 'in 15 minutes',
};
// Mocked throttled error with retryAfter value
export const mockAccountWithThrottledError = {
resetPassword: () => Promise.reject(throttledErrorObjWithRetryAfter),
} as unknown as Account;
const genericThrottledErrorObj = {
errno: 114,
};
// Mocked throttled error without retryAfter value
export const mockAccountWithGenericThrottledError = {
resetPassword: () => Promise.reject(genericThrottledErrorObj),
} as unknown as Account;
export const mockAccountWithUnexpectedError = {
resetPassword: () => Promise.reject('some error'),
} as unknown as Account;
export function createMockResetPasswordWebIntegration(): ResetPasswordBaseIntegration {
return {
type: IntegrationType.Web,
getServiceName: () => MozServices.Default,
};
}
export function createMockResetPasswordOAuthIntegration(
serviceName = MOCK_SERVICE
): ResetPasswordOAuthIntegration {
return {
type: IntegrationType.OAuth,
getRedirectUri: () => MOCK_REDIRECT_URI,
saveOAuthState: () => {},
getService: () => serviceName,
getServiceName: () => serviceName,
};
}

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

@ -11,3 +11,5 @@ complete-reset-password-success-alert = Password set
complete-reset-password-error-alert = Sorry, there was a problem setting your password
complete-reset-password-recovery-key-error-v2 = Sorry, there was a problem checking if you have an account recovery key.
complete-reset-password-recovery-key-link = Reset your password with your account recovery key.
account-restored-success-message = You have successfully restored your account using your account recovery key. Create a new password to secure your data, and store it in a safe location.