diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 37594c54f4..57b6cfa815 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -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 ? ( - <> - - - - - - - ) : ( - <> - - - - { - return CreateCompleteResetPasswordLink(); - }} - {...{ integration }} - > - {({ setLinkStatus, linkModel }) => ( - - )} - - - - )} - + + + + + { - setLinkStatus: React.Dispatch>; - linkModel: TModel; -} - -interface LinkValidatorIntegration { - type: IntegrationType; -} - -interface LinkValidatorProps { - linkType: LinkType; - viewName: string; - createLinkModel: () => TModel; - integration: LinkValidatorIntegration; - children: (props: LinkValidatorChildrenProps) => React.ReactNode; -} - -interface LinkModel { - isValid(): boolean; - email: string | undefined; -} - -const LinkValidator = ({ - linkType, - viewName, - integration, - createLinkModel, - children, -}: LinkValidatorProps & 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( - isValid ? LinkStatus.valid : LinkStatus.damaged - ); - - if ( - linkStatus === LinkStatus.damaged && - linkType === LinkType['reset-password'] - ) { - return ; - } - - if ( - linkStatus === LinkStatus.expired && - linkType === LinkType['reset-password'] && - email !== undefined - ) { - if (isOAuthIntegration(integration)) { - const service = integration.getService(); - const redirectUri = integration.getRedirectUri(); - - return ( - - ); - } - - return ; - } - - return <>{child({ setLinkStatus, linkModel })}; -}; - -export default LinkValidator; diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index 377143f8ba..2956831e52 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -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; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.stories.tsx deleted file mode 100644 index a050ff94d9..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.stories.tsx +++ /dev/null @@ -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(, { 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, - }); -}; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.test.tsx deleted file mode 100644 index 412add82cb..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.test.tsx +++ /dev/null @@ -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(, { 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: 'Don’t 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: 'Don’t have an account recovery key?', - }) - ); - - expect(logViewEvent).toHaveBeenCalledWith( - 'flow', - `lost-recovery-key.${viewName}`, - REACT_ENTRYPOINT - ); - }); - }); -}); diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx deleted file mode 100644 index 13c06af7f5..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx +++ /dev/null @@ -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(''); - const [bannerMessage, setBannerMessage] = useState(); - // 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({ - 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 ; - } - return ( - - - - {bannerMessage && ( - {bannerMessage} - )} - - -

- Please enter the one time use account recovery key you stored in a - safe place to regain access to your Mozilla account. -

-
- - If you reset your password and don’t have account recovery key saved, - some of your data will be erased (including synced server data like - history and bookmarks). - - -
{ - // 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" - > - - { - setTooltipText(''); - }} - prefixDataTestId="account-recovery-confirm-key" - inputRef={register({ required: true })} - /> - - - - - -
- - - { - logViewEvent( - 'flow', - `lost-recovery-key.${viewName}`, - REACT_ENTRYPOINT - ); - }} - > - Don’t have an account recovery key? - - -
- ); -}; - -export default AccountRecoveryConfirmKey; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/interfaces.ts b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/interfaces.ts deleted file mode 100644 index f588882a56..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/interfaces.ts +++ /dev/null @@ -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>; - integration: AccountRecoveryConfirmKeyBaseIntegration; -} - -export interface AccountRecoveryConfirmKeyBaseIntegration { - type: IntegrationType; - getServiceName: () => ReturnType; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx deleted file mode 100644 index 2a0c84e32f..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx +++ /dev/null @@ -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, - integration: AccountRecoveryConfirmKeyBaseIntegration -) => { - const urlQueryData = mockUrlQueryData(params); - const history = createHistoryWithQuery( - route, - new URLSearchParams(params).toString() - ); - - return { - Subject: () => ( - { - return new CompleteResetPasswordLink(urlQueryData); - }} - {...{ integration }} - > - {({ setLinkStatus, linkModel }) => ( - - )} - - ), - route, - history, - appCtx: { - ...mockAppContext({ - ...createAppContext(), - account, - }), - }, - }; -}; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/container.tsx deleted file mode 100644 index 86dd126cba..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/container.tsx +++ /dev/null @@ -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 ; -}; - -export default AccountRecoveryResetPasswordContainer; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/en.ftl b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/en.ftl deleted file mode 100644 index 87d4ffe2a4..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/en.ftl +++ /dev/null @@ -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 diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.stories.tsx deleted file mode 100644 index 19625f8773..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.stories.tsx +++ /dev/null @@ -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 ( - - - - - - ); -}; - -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); -}; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx deleted file mode 100644 index 81ff7c016c..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx +++ /dev/null @@ -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 = , account = mockAccount()) => { - const history = createHistoryWithQuery(route); - return renderWithRouter( - ui, - { - route, - history, - }, - mockAppContext({ - ...createAppContext(), - account, - }) - ); -}; - -const Subject = ({ integration = createMockSyncDesktopV3Integration() }) => ( - -); - -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( - - ); - - 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(, 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(, 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(, 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', - }); - }); - }); -}); diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.tsx deleted file mode 100644 index b633a8b4f0..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.tsx +++ /dev/null @@ -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 & { - state: AccountRecoveryResetPasswordLocationState; - }; - // TODO: This should be done in a container component - const verificationInfo = CreateVerificationInfo(); - - const [bannerState, setBannerState] = - useState( - 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( - linkIsValid ? LinkStatus.valid : LinkStatus.damaged - ); - - const onFocusMetricsEvent = () => { - logViewEvent(settingsViewName, `${viewName}.engage`); - }; - - const { handleSubmit, register, getValues, errors, formState, trigger } = - useForm({ - mode: 'onTouched', - criteriaMode: 'all', - defaultValues: { - newPassword: '', - confirmPassword: '', - }, - }); - - if (linkStatus === 'damaged') { - return ; - } - - if (linkStatus === 'expired') { - if (isOAuthIntegration(integration)) { - const service = integration.getService(); - const redirectUri = integration.getRedirectUri(); - return ( - - ); - } - - return ( - - ); - } - - return ( - - - {AccountRecoveryResetPasswordBannerState.Redirecting === bannerState && ( - - -

Redirecting

-
-
- )} - {AccountRecoveryResetPasswordBannerState.UnexpectedError === - bannerState && ( - - -

Unexpected error encountered

-
-
- )} - - {AccountRecoveryResetPasswordBannerState.PasswordResetSuccess === - bannerState && ( - - -

Password set

-
-
- )} - - -

- 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. -

-
- - {/* 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. */} - -
- { - onSubmit(data); - }, - (err) => { - console.error(err); - } - )} - email={verificationInfo.email} - loading={false} - onFocusMetricsEvent={onFocusMetricsEvent} - /> -
- - -
- ); - - 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; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/interfaces.ts deleted file mode 100644 index 29cf5b092c..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/interfaces.ts +++ /dev/null @@ -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; - getRedirectUri: () => ReturnType; - isSync: () => ReturnType; -} - -export interface AccountRecoveryResetPasswordBaseIntegration { - type: IntegrationType; - data: { - uid: BaseIntegrationData['uid']; - resetPasswordConfirm: BaseIntegrationData['resetPasswordConfirm']; - }; -} - -export type AccountRecoveryResetPasswordIntegration = - | AccountRecoveryResetPasswordOAuthIntegration - | AccountRecoveryResetPasswordBaseIntegration; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/mocks.tsx deleted file mode 100644 index b6d178a5c4..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/mocks.tsx +++ /dev/null @@ -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, - }, - }; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx deleted file mode 100644 index 425701aaf5..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx +++ /dev/null @@ -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 ( - { - return CreateCompleteResetPasswordLink(); - }} - {...{ integration }} - > - {({ setLinkStatus, linkModel }) => ( - - )} - - ); -}; - -export default CompleteResetPasswordContainer; diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.stories.tsx deleted file mode 100644 index 6f96e08d03..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.stories.tsx +++ /dev/null @@ -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; -}; - -function renderStory( - { account = mockAccountNoRecoveryKey, params }: RenderStoryOptions = {}, - storyName?: string -) { - const story = () => - produceComponent( - , - {}, - { - ...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, - }); -}; diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx deleted file mode 100644 index 8e49b6e356..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx +++ /dev/null @@ -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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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 - ) => { - const fxaLoginSignedInUserSpy = jest.spyOn( - firefox, - 'fxaLoginSignedInUser' - ); - render(, 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); - }); - }); - }); - }); -}); diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx deleted file mode 100644 index fb31887002..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx +++ /dev/null @@ -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 & { - state: CompleteResetPasswordLocationState; - }; - const ftlMsgResolver = useFtlMsgResolver(); - - const { handleSubmit, register, getValues, errors, formState, trigger } = - useForm({ - 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( - <> - -

- Sorry, there was a problem checking if you have an account - recovery key. -

-
- - - Reset your password with your account recovery key. - - - - ); - } - }, - [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 ; - } - return ( - - - - {bannerMessage && ( - {bannerMessage} - )} - - - When you reset your password, you reset your account. You may lose some - of your personal information (including history, bookmarks, and - passwords). That’s because we encrypt your data with your password to - protect your privacy. You’ll still keep any subscriptions you may have - and Pocket data will not be affected. - - - {/* 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. */} - -
- - onSubmit({ - newPassword, - token: linkModel.token, - code: linkModel.code, - email: linkModel.email, - emailToHashWith: linkModel.emailToHashWith, - }) - )} - loading={false} - onFocusMetricsEvent={onFocusMetricsEvent} - /> -
- -
- ); -}; - -export default CompleteResetPassword; diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/interfaces.ts deleted file mode 100644 index f0a525f08e..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/interfaces.ts +++ /dev/null @@ -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; -} - -export type CompleteResetPasswordIntegration = - | CompleteResetPasswordOAuthIntegration - | IntegrationSubsetType; - -export interface CompleteResetPasswordProps { - linkModel: CompleteResetPasswordLink; - setLinkStatus: React.Dispatch>; - integration: CompleteResetPasswordIntegration; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx deleted file mode 100644 index 1724af449a..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx +++ /dev/null @@ -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; -}) => { - 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 ( - { - return new CompleteResetPasswordLink(urlQueryData); - }} - // TODO worth fixing this type? - integration={completeResetPasswordIntegration as Integration} - > - {({ setLinkStatus, linkModel }) => ( - - )} - - ); -}; - -function createMockResetPasswordOAuthIntegration( - isSync = false -): CompleteResetPasswordOAuthIntegration { - return { - type: IntegrationType.OAuth, - isSync: () => isSync, - data: { - uid: MOCK_UID, - }, - }; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/en.ftl b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/en.ftl deleted file mode 100644 index 4f3182a859..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/en.ftl +++ /dev/null @@ -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. diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.stories.tsx deleted file mode 100644 index dde8920ba7..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.stories.tsx +++ /dev/null @@ -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( - , - '/confirm_reset_password' - ); diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx deleted file mode 100644 index 1c28361141..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx +++ /dev/null @@ -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 = () => ( - -); - -describe('ConfirmResetPassword page', () => { - // TODO enable l10n testing - // let bundle: FluentBundle; - // beforeAll(async () => { - // bundle = await getFtlBundle('settings'); - // }); - - it('renders as expected', () => { - renderWithHistory(); - - // 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( - , - 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(); - expect(usePageViewEvent).toHaveBeenCalledWith(viewName, REACT_ENTRYPOINT); - }); - - it('renders a "Remember your password?" link', () => { - renderWithHistory(); - expect(screen.getByText('Remember your password?')).toBeVisible(); - expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible(); - }); -}); diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx deleted file mode 100644 index 1716a51d9e..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx +++ /dev/null @@ -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.none - ); - const [errorMessage, setErrorMessage] = useState(); - const [isPolling, setIsPolling] = useState( - POLLING_INTERVAL_MS - ); - const [currentPasswordForgotToken, setCurrentPasswordForgotToken] = - useState(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 ( - - - - - ); -}; - -export default ConfirmResetPassword; diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/interfaces.ts deleted file mode 100644 index 2ddeea5603..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/interfaces.ts +++ /dev/null @@ -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; - getService: () => ReturnType; -} - -export type ConfirmResetPasswordIntegration = - | ConfirmResetPasswordOAuthIntegration - | IntegrationSubsetType; - -export interface ConfirmResetPasswordLocationState { - email: string; - passwordForgotToken: string; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/mocks.tsx deleted file mode 100644 index 768895c09f..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/mocks.tsx +++ /dev/null @@ -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, - }; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/en.ftl b/packages/fxa-settings/src/pages/ResetPassword/en.ftl deleted file mode 100644 index e1fd0d7105..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/en.ftl +++ /dev/null @@ -1,16 +0,0 @@ -## ResetPassword page - -# Strings within the elements appear as a subheading. -# If more appropriate in a locale, the string within the , "to continue to account settings" can stand alone as "Continue to account settings" -reset-password-heading-w-default-service = Reset password to continue to account settings -# Strings within the elements appear as a subheading. -# If more appropriate in a locale, the string within the , "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 to continue to { $serviceName } -reset-password-warning-message-2 = Note: When you reset your password, you reset your account. You may lose some of your personal information (including history, bookmarks, and passwords). That’s because we encrypt your data with your password to protect your privacy. You’ll 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 diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.stories.tsx deleted file mode 100644 index 2cdd6fc24d..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/index.stories.tsx +++ /dev/null @@ -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; - queryParams?: string; - integrationType?: IntegrationType; -}; - -function renderStory({ - account, - props, - queryParams, - integrationType = IntegrationType.Web, -}: RenderStoryOptions = {}) { - const integration = - integrationType === IntegrationType.OAuth - ? createMockResetPasswordOAuthIntegration() - : createMockResetPasswordWebIntegration(); - - return renderStoryWithHistory( - , - '/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 }); diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx deleted file mode 100644 index 4940d507b6..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx +++ /dev/null @@ -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 = () => ( - -); - -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(); - // 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( - - ); - 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(); - 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( - , - 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(, 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(, 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(, 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(, 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(, 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( - 'You’ve 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(, 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')); - }); -}); diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.tsx deleted file mode 100644 index b5ad68c617..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/index.tsx +++ /dev/null @@ -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(''); - const [errorMessage, setErrorMessage] = useState(''); - const [hasFocused, setHasFocused] = useState(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({ - 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 - ), - }); - } - }, [flowQueryParams, ftlMsgResolver, getValues, submitEmail]); - - const ControlledLinkRememberPassword = ({ - control, - }: { - control: Control; - }) => { - const email: string = useWatch({ - control, - name: 'email', - defaultValue: getValues().email, - }); - return ; - }; - - return ( - - - - {errorMessage && ( - -

{errorMessage}

-
- )} - - - When you reset your password, you reset your account. You may lose some - of your personal information (including history, bookmarks, and - passwords). That’s because we encrypt your data with your password to - protect your privacy. You’ll still keep any subscriptions you may have - and Pocket data will not be affected. - -
- {/* if email is forced, display a read-only email */} - {/* do not provide input field to modify the email */} - {forceAuth && prefillEmail && ( -

- {prefillEmail} -

- )} - - {/* if email is not forced, display input field */} - {!forceAuth && ( - - - - )} - - - - -
- - -
- ); -}; - -export default ResetPassword; diff --git a/packages/fxa-settings/src/pages/ResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPassword/interfaces.ts deleted file mode 100644 index 19c92e902b..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/interfaces.ts +++ /dev/null @@ -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; - saveOAuthState: () => ReturnType; - getServiceName: () => ReturnType; - getService: () => ReturnType; -} - -export interface ResetPasswordBaseIntegration { - type: IntegrationType; - getServiceName: () => ReturnType; -} - -type ResetPasswordIntegration = - | ResetPasswordOAuthIntegration - | ResetPasswordBaseIntegration; - -export interface ResetPasswordProps { - prefillEmail?: string; - forceAuth?: boolean; - serviceName?: MozServices; - integration: ResetPasswordIntegration; - flowQueryParams?: QueryParams; -} - -export interface ResetPasswordFormData { - email: string; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/mocks.tsx deleted file mode 100644 index 22da4b7279..0000000000 --- a/packages/fxa-settings/src/pages/ResetPassword/mocks.tsx +++ /dev/null @@ -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, - }; -} diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/en.ftl b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/en.ftl similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/en.ftl rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/en.ftl diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/en.ftl b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/en.ftl similarity index 85% rename from packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/en.ftl rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/en.ftl index 08de00c2db..ddded7e0f8 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/en.ftl +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/en.ftl @@ -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. diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/index.stories.tsx similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/index.stories.tsx rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/index.stories.tsx diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/index.test.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/index.test.tsx similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/index.test.tsx rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/index.test.tsx diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/index.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/index.tsx similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/index.tsx rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/index.tsx diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/interfaces.ts b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/interfaces.ts similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordConfirmed/interfaces.ts rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordConfirmed/interfaces.ts diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/en.ftl b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/en.ftl similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/en.ftl rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/en.ftl diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/index.stories.tsx similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/index.stories.tsx rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/index.stories.tsx diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/index.test.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/index.test.tsx similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/index.test.tsx rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/index.test.tsx diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/index.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/index.tsx similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/index.tsx rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/index.tsx diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/interfaces.ts b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/interfaces.ts similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/interfaces.ts rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/interfaces.ts diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/mocks.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/mocks.tsx similarity index 100% rename from packages/fxa-settings/src/pages/ResetPassword/ResetPasswordWithRecoveryKeyVerified/mocks.tsx rename to packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPasswordWithRecoveryKeyVerified/mocks.tsx