diff --git a/packages/fxa-graphql-api/src/gql/dto/payload/password-forgot.ts b/packages/fxa-graphql-api/src/gql/dto/payload/password-forgot.ts index fbaf56333b8..44b47c64a5c 100644 --- a/packages/fxa-graphql-api/src/gql/dto/payload/password-forgot.ts +++ b/packages/fxa-graphql-api/src/gql/dto/payload/password-forgot.ts @@ -64,4 +64,7 @@ export class AccountResetPayload { @Field({ nullable: true }) public keyFetchToken?: string; + + @Field({ nullable: true }) + public unwrapBKey?: string; } diff --git a/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.test.tsx b/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.test.tsx index 416acdad2ff..54757183785 100644 --- a/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.test.tsx @@ -3,11 +3,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from 'react'; -import { LocationProvider } from '@reach/router'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { LinkExpiredResetPassword } from '.'; -import { mockAppContext, MOCK_ACCOUNT } from '../../models/mocks'; -import { Account, AppContext } from '../../models'; +import { + mockAppContext, + MOCK_ACCOUNT, + createHistoryWithQuery, + renderWithRouter, + createAppContext, +} from '../../models/mocks'; +import { Account } from '../../models'; import { FIREFOX_NOREPLY_EMAIL } from 'fxa-settings/src/constants'; const viewName = 'example-view-name'; @@ -17,25 +22,31 @@ jest.mock('@reach/router', () => ({ ...jest.requireActual('@reach/router'), })); -function renderLinkExpiredResetPasswordWithAccount(account: Account) { - render( - - - - - +const route = '/bloop'; +const renderWithHistory = (ui: any, queryParams = '', account?: Account) => { + const history = createHistoryWithQuery(route, queryParams); + return renderWithRouter( + ui, + { + route, + history, + }, + mockAppContext({ + ...createAppContext(history), + ...(account && { account }), + }) ); -} +}; describe('LinkExpiredResetPassword', () => { - const account = {} as unknown as Account; + const component = ; afterEach(() => { jest.clearAllMocks(); }); it('renders the component as expected for an expired Reset Password link', () => { - renderLinkExpiredResetPasswordWithAccount(account); + renderWithHistory(component); screen.getByRole('heading', { name: 'Reset password link expired', @@ -47,10 +58,10 @@ describe('LinkExpiredResetPassword', () => { }); it('displays a success banner when clicking on "receive a new link" is successful', async () => { const account = { - resendResetPassword: jest.fn().mockResolvedValue(true), + resetPassword: jest.fn().mockResolvedValue(true), } as unknown as Account; - renderLinkExpiredResetPasswordWithAccount(account); + renderWithHistory(component, '', account); const receiveNewLinkButton = screen.getByRole('button', { name: 'Receive new link', }); @@ -65,10 +76,10 @@ describe('LinkExpiredResetPassword', () => { }); it('displays an error banner when clicking on "receive a new link" is unsuccessful', async () => { const account = { - resendResetPassword: jest.fn().mockRejectedValue('error'), + resetPassword: jest.fn().mockRejectedValue('error'), } as unknown as Account; - renderLinkExpiredResetPasswordWithAccount(account); + renderWithHistory(component, '', account); const receiveNewLinkButton = screen.getByRole('button', { name: 'Receive new link', }); diff --git a/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.tsx b/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.tsx index f6abdc51409..ab95d12b77c 100644 --- a/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.tsx +++ b/packages/fxa-settings/src/components/LinkExpiredResetPassword/index.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React, { useState } from 'react'; -import { useAccount } from '../../models'; +import { CreateRelier, useAccount } from '../../models'; import { ResendStatus } from '../../lib/types'; import { logViewEvent } from 'fxa-settings/src/lib/metrics'; import { REACT_ENTRYPOINT } from 'fxa-settings/src/constants'; @@ -20,6 +20,8 @@ export const LinkExpiredResetPassword = ({ }: LinkExpiredResetPasswordProps) => { // TODO in FXA-7630 add metrics event and associated tests for users hitting the LinkExpired page const account = useAccount(); + const relier = CreateRelier(); + const serviceName = relier.getServiceName(); const [resendStatus, setResendStatus] = useState( ResendStatus['not sent'] @@ -27,7 +29,7 @@ export const LinkExpiredResetPassword = ({ const resendResetPasswordLink = async () => { try { - await account.resendResetPassword(email); + await account.resetPassword(email, serviceName); logViewEvent(viewName, 'resend', REACT_ENTRYPOINT); setResendStatus(ResendStatus['sent']); } catch (e) { diff --git a/packages/fxa-settings/src/lib/channels/helpers.ts b/packages/fxa-settings/src/lib/channels/helpers.ts index 54d35897c05..d2bdd678bf9 100644 --- a/packages/fxa-settings/src/lib/channels/helpers.ts +++ b/packages/fxa-settings/src/lib/channels/helpers.ts @@ -2,93 +2,49 @@ * 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 { AccountData } from '../../models'; +import firefox, { FxALoginRequest } from './firefox'; -// const ALLOWED_LOGIN_FIELDS = [ -// 'declinedSyncEngines', -// 'email', -// 'keyFetchToken', -// 'offeredSyncEngines', -// 'sessionToken', -// 'services', -// 'uid', -// 'unwrapBKey', -// 'verified', -// ]; - -// const REQUIRED_LOGIN_FIELDS = [ -// 'email', -// 'keyFetchToken', -// 'sessionToken', -// 'uid', -// 'unwrapBKey', -// 'verified', -// ]; - -// Might need to go on the Integration? +// TODO: might need to go on Integration? // let uidOfLoginNotification: hexstring = ''; -// function hasRequiredLoginFields(loginData) { -// const loginFields = Object.keys(loginData); -// return ( -// REQUIRED_LOGIN_FIELDS.filter((field) => !loginFields.includes(field)) -// .length === 0 -// ); -// } - -/** - * Get login data from `account` to send to the browser. - * All returned keys have a defined value. - */ -// function getLoginData(account: AccountData) { -// let loginData: Partial = {}; -// for (const key of ALLOWED_LOGIN_FIELDS) { -// loginData[key] = account[key]; -// } -// // TODO: account for multiservice when we combine reliers -// // const isMultiService = this.relier && this.relier.get('multiService'); -// // if (isMultiService) { -// // loginData = this._formatForMultiServiceBrowser(loginData); -// // } -// loginData.verified = !!loginData.verified; -// // TODO: this is set in the `beforeSignIn` auth-broker method -// // loginData.verifiedCanLinkAccount = !!this._verifiedCanLinkEmail; -// return Object.fromEntries( -// Object.entries(loginData).filter(([key, value]) => value !== undefined) -// ); -// } +const REQUIRED_LOGIN_FIELDS: Array = [ + 'authAt', + 'email', + 'keyFetchToken', + 'sessionToken', + 'uid', + 'unwrapBKey', + 'verified', +]; -// TODO in FXA-7172 export function notifyFirefoxOfLogin( - account: AccountData, + accountData: FxALoginRequest, isSessionVerified: boolean ) { - // only notify the browser of the login if the user does not have - // to verify their account/session - if (!isSessionVerified) { - return; - } - - /** - * Workaround for #3078. If the user signs up but does not verify - * their account, then visit `/` or `/settings`, they are + /* Only notify the browser of the login if the user 1) does not have to + * verify their account/session and 2) when all required fields are present, + * which is a workaround for #3078. If the user signs up but does not + * verify their account, then visit `/` or `/settings`, they are * redirected to `/confirm` which attempts to notify the browser of * login. Since `unwrapBKey` and `keyFetchToken` are not persisted to * disk, the passed in account lacks these items. The browser can't - * do anything without this data, so don't actually send the message. - * - * Also works around #3514. With e10s enabled, localStorage in - * about:accounts and localStorage in the verification page are not - * shared. This lack of shared state causes the original tab of - * a password reset from about:accounts to not have all the - * required data. The verification tab sends a WebChannel message - * already, so no need here too. - */ - // const loginData = getLoginData(account); - // if (!hasRequiredLoginFields(loginData)) { - // return; + * do anything without this data, so don't actually send the message. */ + + if ( + !isSessionVerified || + !accountData.verified || + !REQUIRED_LOGIN_FIELDS.every((key) => key in accountData) + ) { + return; + } + + // TODO: account for multiservice TODO with relier/integration combination + // const isMultiService = this.relier && this.relier.get('multiService'); + // if (isMultiService) { + // loginData = this._formatForMultiServiceBrowser(loginData); // } + // TODO with relier/integration combination or during login tickets. // Only send one login notification per uid to avoid race // conditions within the browser. Two attempts to send // a login message occur for users that verify while @@ -98,6 +54,5 @@ export function notifyFirefoxOfLogin( // if (loginData.uid !== uidOfLoginNotification) { // uidOfLoginNotification = loginData.uid; - // send web channel LOGIN command with loginData - // } + firefox.fxaLogin(accountData); } diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 08c4a6ef5fc..c5e258a4d1e 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -17,6 +17,8 @@ import Storage from '../lib/storage'; import random from '../lib/random'; import { AuthUiErrorNos, AuthUiErrors } from '../lib/auth-errors/auth-errors'; import { GET_SESSION_VERIFIED } from './Session'; +import { MozServices } from '../lib/types'; +import { CreateRelier } from './reliers'; export interface DeviceLocation { city: string | null; @@ -538,7 +540,10 @@ export class Account implements AccountData { }); } - async resetPassword(email: string): Promise { + async resetPassword( + email: string, + service?: string + ): Promise { try { const result = await this.apolloClient.mutate({ mutation: gql` @@ -550,7 +555,17 @@ export class Account implements AccountData { } } `, - variables: { input: { email } }, + variables: { + input: { + email, + // Only include the `service` option if the service is Sync. + // This becomes a query param (service=sync) on the email link. + // We need to modify this in FXA-7657 to send the `client_id` param + // when we work on the OAuth flow. + ...(service && + service === MozServices.FirefoxSync && { service: 'sync' }), + }, + }, }); return result.data.passwordForgotSendCode; } catch (err) { @@ -614,51 +629,6 @@ export class Account implements AccountData { } } - async resendResetPassword( - email: string - ): Promise { - try { - const result = await this.apolloClient.mutate({ - mutation: gql` - mutation passwordForgotSendCode( - $input: PasswordForgotSendCodeInput! - ) { - passwordForgotSendCode(input: $input) { - clientMutationId - passwordForgotToken - } - } - `, - variables: { input: { email } }, - }); - return result.data.passwordForgotSendCode; - } catch (err) { - const graphQlError = ((err as ApolloError) || (err as ThrottledError)) - .graphQLErrors[0]; - const errno = graphQlError.extensions?.errno; - if ( - (err as ThrottledError) && - errno && - AuthUiErrorNos[errno] && - errno === AuthUiErrors.THROTTLED.errno - ) { - const throttledErrorWithRetryAfter = { - ...AuthUiErrorNos[errno], - retryAfter: graphQlError.extensions?.retryAfter, - retryAfterLocalized: graphQlError.extensions?.retryAfterLocalized, - }; - throw throttledErrorWithRetryAfter; - } else if ( - errno && - AuthUiErrorNos[errno] && - errno !== AuthUiErrors.THROTTLED.errno - ) { - throw AuthUiErrorNos[errno]; - } - throw AuthUiErrors.UNEXPECTED_ERROR; - } - } - /** * Verify a passwordForgotToken, which returns an accountResetToken that can * be used to perform the actual password reset. @@ -721,6 +691,10 @@ export class Account implements AccountData { clientMutationId sessionToken uid + authAt + keyFetchToken + verified + unwrapBKey } } `, @@ -729,12 +703,13 @@ export class Account implements AccountData { accountResetToken, email, newPassword, - options: { sessionToken: true }, + options: { sessionToken: true, keys: true }, }, }, }); currentAccount(getOldSettingsData(accountReset)); sessionToken(accountReset.sessionToken); + return accountReset; } catch (err) { const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno; if (errno && AuthUiErrorNos[errno]) { @@ -1280,7 +1255,7 @@ export class Account implements AccountData { opts.password, opts.recoveryKeyId, { kB: opts.kB }, - { sessionToken: true } + { sessionToken: true, keys: true } ); currentAccount(currentAccount(getOldSettingsData(data))); sessionToken(data.sessionToken); @@ -1293,5 +1268,6 @@ export class Account implements AccountData { }, }, }); + return data; } } diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.test.tsx index a33b8197c2d..2fe440895b3 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.test.tsx @@ -10,7 +10,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { logPageViewEvent, logViewEvent } from '../../../lib/metrics'; import { viewName } from '.'; import { - Subject, MOCK_RECOVERY_KEY, MOCK_RESET_TOKEN, MOCK_RECOVERY_KEY_ID, @@ -19,6 +18,7 @@ import { paramsWithMissingToken, paramsWithMissingCode, paramsWithMissingEmail, + renderSubject, } from './mocks'; import { REACT_ENTRYPOINT } from '../../../constants'; import { Account } from '../../../models'; @@ -72,11 +72,11 @@ const accountWithValidResetToken = { .mockResolvedValue({ accountResetToken: MOCK_RESET_TOKEN }), } as unknown as Account; -const renderSubject = ({ +const renderSubjectWithDefaults = ({ account = accountWithValidResetToken, params = mockCompleteResetPasswordParams, } = {}) => { - render(); + renderSubject(account, params); }; describe('PageAccountRecoveryConfirmKey', () => { @@ -88,7 +88,7 @@ describe('PageAccountRecoveryConfirmKey', () => { // }); it('renders as expected when the link is valid', async () => { - renderSubject(); + renderSubjectWithDefaults(); // testAllL10n(screen, bundle); await screen.findByRole('heading', { @@ -114,7 +114,7 @@ describe('PageAccountRecoveryConfirmKey', () => { throw AuthUiErrors.INVALID_TOKEN; }), } as unknown as Account; - renderSubject({ account: accountWithTokenError }); + renderSubjectWithDefaults({ account: accountWithTokenError }); await screen.findByRole('heading', { name: 'Reset password link expired', @@ -123,7 +123,7 @@ describe('PageAccountRecoveryConfirmKey', () => { describe('renders the component as expected when provided with a damaged link', () => { it('with missing token', async () => { - renderSubject({ params: paramsWithMissingToken }); + renderSubjectWithDefaults({ params: paramsWithMissingToken }); await screen.findByRole('heading', { name: 'Reset password link damaged', @@ -133,14 +133,14 @@ describe('PageAccountRecoveryConfirmKey', () => { ); }); it('with missing code', async () => { - renderSubject({ params: paramsWithMissingCode }); + renderSubjectWithDefaults({ params: paramsWithMissingCode }); await screen.findByRole('heading', { name: 'Reset password link damaged', }); }); it('with missing email', async () => { - renderSubject({ params: paramsWithMissingEmail }); + renderSubjectWithDefaults({ params: paramsWithMissingEmail }); await screen.findByRole('heading', { name: 'Reset password link damaged', @@ -151,7 +151,7 @@ describe('PageAccountRecoveryConfirmKey', () => { describe('submit', () => { describe('displays error and does not allow submission', () => { it('with an empty recovery key', async () => { - renderSubject(); + renderSubjectWithDefaults(); fireEvent.click( await screen.findByRole('button', { name: 'Confirm account recovery key', @@ -170,7 +170,7 @@ describe('PageAccountRecoveryConfirmKey', () => { }); it('with less than 32 characters', async () => { - renderSubject(); + renderSubjectWithDefaults(); const submitButton = await screen.findByRole('button', { name: 'Confirm account recovery key', }); @@ -191,7 +191,7 @@ describe('PageAccountRecoveryConfirmKey', () => { }); it('with more than 32 characters', async () => { - renderSubject({ account: accountWithValidResetToken }); + renderSubjectWithDefaults({ account: accountWithValidResetToken }); const submitButton = await screen.findByRole('button', { name: 'Confirm account recovery key', }); @@ -206,7 +206,7 @@ describe('PageAccountRecoveryConfirmKey', () => { }); it('with invalid Crockford base32', async () => { - renderSubject(); + renderSubjectWithDefaults(); const submitButton = await screen.findByRole('button', { name: 'Confirm account recovery key', }); @@ -222,7 +222,7 @@ describe('PageAccountRecoveryConfirmKey', () => { }); it('submits successfully with spaces in recovery key', async () => { - renderSubject(); + renderSubjectWithDefaults(); const submitButton = await screen.findByRole('button', { name: 'Confirm account recovery key', }); @@ -270,7 +270,7 @@ describe('PageAccountRecoveryConfirmKey', () => { }), } as unknown as Account; - renderSubject({ account: accountWithKeyInvalidOnce }); + renderSubjectWithDefaults({ account: accountWithKeyInvalidOnce }); await screen.findByRole('heading', { level: 1, name: 'Reset password with account recovery key to continue to account settings', @@ -313,7 +313,7 @@ describe('PageAccountRecoveryConfirmKey', () => { describe('emits metrics events', () => { afterEach(() => jest.clearAllMocks()); it('on engage, submit, success', async () => { - renderSubject(); + renderSubjectWithDefaults(); const submitButton = await screen.findByRole('button', { name: 'Confirm account recovery key', }); @@ -350,7 +350,7 @@ describe('PageAccountRecoveryConfirmKey', () => { resetPasswordStatus: jest.fn().mockResolvedValue(true), getRecoveryKeyBundle: jest.fn().mockRejectedValue(new Error('Boop')), } as unknown as Account; - renderSubject({ account: accountWithInvalidKey }); + renderSubjectWithDefaults({ account: accountWithInvalidKey }); const submitButton = await screen.findByRole('button', { name: 'Confirm account recovery key', diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx index 0a1527e5c1c..82a30dc8ecc 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/mocks.tsx @@ -6,7 +6,13 @@ import React from 'react'; import { MozServices } from '../../../lib/types'; import { LocationProvider } from '@reach/router'; import { Account, AppContext } from '../../../models'; -import { mockAppContext, MOCK_ACCOUNT } from '../../../models/mocks'; +import { + mockAppContext, + MOCK_ACCOUNT, + createHistoryWithQuery, + renderWithRouter, + createAppContext, +} from '../../../models/mocks'; import { LinkType } from 'fxa-settings/src/lib/types'; import LinkValidator from '../../../components/LinkValidator'; @@ -77,38 +83,37 @@ class StorageDataMock extends StorageData { } } -export const Subject = ({ - account, - params, -}: { - account: Account; - params: Record; -}) => { +const route = '/account_recovery_confirm_key'; +export const renderSubject = ( + account: Account, + params?: Record +) => { const windowWrapper = new ReachRouterWindow(); const urlQueryData = mockUrlQueryData(params); - - return ( - { + return new CompleteResetPasswordLink(urlQueryData); + }} > - - { - return new CompleteResetPasswordLink(urlQueryData); - }} - > - {({ setLinkStatus, params }) => ( - - )} - - - + {({ setLinkStatus, params }) => ( + + )} + , + { + route, + history, + }, + mockAppContext({ + ...createAppContext(history), + ...(account && { account }), + storageData: new StorageDataMock(windowWrapper), + }) ); }; diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx index 84222d2c5cd..947756f7561 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx @@ -104,6 +104,22 @@ describe('AccountRecoveryResetPassword page', () => { jest.restoreAllMocks(); } + // const route = '/confirm_reset_password'; + // const renderWithHistory = (ui: any, queryParams = '', account?: Account) => { + // const history = createHistoryWithQuery(route, queryParams); + // return renderWithRouter( + // ui, + // { + // route, + // history, + // }, + // mockAppContext({ + // ...createAppContext(history), + // ...(account && { account }), + // }) + // ); + // }; + async function renderPage() { render( @@ -248,7 +264,9 @@ describe('AccountRecoveryResetPassword page', () => { describe('successful reset', () => { beforeEach(async () => { mockAccount.setLastLogin = jest.fn(); - mockAccount.resetPasswordWithRecoveryKey = jest.fn(); + mockAccount.resetPasswordWithRecoveryKey = jest + .fn() + .mockResolvedValue(mocks.MOCK_RESET_DATA); mockAccount.isSessionVerifiedAuthClient = jest.fn(); mockAccount.hasTotpAuthClient = jest.fn().mockResolvedValue(false); @@ -327,7 +345,9 @@ describe('AccountRecoveryResetPassword page', () => { beforeEach(async () => { window.location.href = originalWindow.href; mockAccount.setLastLogin = jest.fn(); - mockAccount.resetPasswordWithRecoveryKey = jest.fn(); + mockAccount.resetPasswordWithRecoveryKey = jest + .fn() + .mockResolvedValue(mocks.MOCK_RESET_DATA); mockAccount.isSessionVerifiedAuthClient = jest.fn(); mockAccount.hasTotpAuthClient = jest.fn().mockResolvedValue(true); await renderPage(); diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.tsx index 861bafb4609..653378d3e50 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.tsx @@ -252,7 +252,9 @@ const AccountRecoveryResetPassword = ({ verificationInfo.emailToHashWith || verificationInfo.email, }; - await account.resetPasswordWithRecoveryKey(options); + const accountResetData = await account.resetPasswordWithRecoveryKey( + options + ); // must come after completeResetPassword since that receives the sessionToken // required for this check const sessionIsVerified = await account.isSessionVerifiedAuthClient(); @@ -267,8 +269,22 @@ const AccountRecoveryResetPassword = ({ logViewEvent(viewName, 'verification.success'); switch (integration.type) { + // NOTE: SyncBasic check is temporary until we implement codes + // See https://docs.google.com/document/d/1K4AD69QgfOCZwFLp7rUcMOkOTslbLCh7jjSdR9zpAkk/edit#heading=h.kkt4eylho93t case IntegrationType.SyncDesktop: - notifyFirefoxOfLogin(account, sessionIsVerified); + case IntegrationType.SyncBasic: + notifyFirefoxOfLogin( + { + authAt: accountResetData.authAt, + email: verificationInfo.email, + keyFetchToken: accountResetData.keyFetchToken, + sessionToken: accountResetData.sessionToken, + uid: accountResetData.uid, + unwrapBKey: accountResetData.unwrapBKey, + verified: accountResetData.verified, + }, + sessionIsVerified + ); break; case IntegrationType.OAuth: if ( diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/mocks.tsx index 72889999985..838e72a861f 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/mocks.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/mocks.tsx @@ -277,3 +277,10 @@ export function mockContext() { } export const MOCK_SERVICE_NAME = MozServices.FirefoxSync; +export const MOCK_RESET_DATA = { + authAt: 12345, + keyFetchToken: 'keyFetchToken', + sessionToken: 'sessionToken', + unwrapBKey: 'unwrapBKey', + verified: true, +}; diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx index f95cc6be622..3fb59a74659 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx @@ -14,12 +14,14 @@ import { Account } from '../../../models'; import { logPageViewEvent } from '../../../lib/metrics'; import { REACT_ENTRYPOINT, SHOW_BALLOON_TIMEOUT } from '../../../constants'; import { + MOCK_RESET_DATA, mockCompleteResetPasswordParams, paramsWithMissingCode, paramsWithMissingEmail, paramsWithMissingEmailToHashWith, paramsWithMissingToken, paramsWithSyncDesktop, + renderSubject, Subject, } from './mocks'; import { notifyFirefoxOfLogin } from '../../../lib/channels/helpers'; @@ -64,6 +66,23 @@ const mockLocation = () => { }; }; +// // TODO: be consistent +// const route = '/complete_reset_password'; +// const renderWithHistory = (ui: any, queryParams = '', account?: Account) => { +// const history = createHistoryWithQuery(route, queryParams); +// return renderWithRouter( +// ui, +// { +// route, +// history, +// }, +// mockAppContext({ +// ...createAppContext(history), +// ...(account && { account }), +// }) +// ); +// }; + jest.mock('../../../lib/channels/helpers', () => { return { notifyFirefoxOfLogin: jest.fn(), @@ -76,9 +95,9 @@ jest.mock('@reach/router', () => ({ useLocation: () => mockLocation(), })); -function renderSubject(account: Account, params?: Record) { - render(); -} +// function renderSubject(account: Account, params?: Record) { +// render(); +// } describe('CompleteResetPassword page', () => { // TODO: enable l10n tests when they've been updated to handle embedded tags in ftl strings @@ -92,7 +111,7 @@ describe('CompleteResetPassword page', () => { account = { resetPasswordStatus: jest.fn().mockResolvedValue(true), - completeResetPassword: jest.fn().mockResolvedValue(true), + completeResetPassword: jest.fn().mockResolvedValue(MOCK_RESET_DATA), hasRecoveryKey: jest.fn().mockResolvedValue(false), hasTotpAuthClient: jest.fn().mockResolvedValue(false), isSessionVerifiedAuthClient: jest.fn().mockResolvedValue(true), @@ -140,6 +159,7 @@ describe('CompleteResetPassword page', () => { it('renders the component as expected when provided with an expired link', async () => { account = { + ...account, resetPasswordStatus: jest.fn().mockResolvedValue(false), } as unknown as Account; @@ -252,7 +272,7 @@ describe('CompleteResetPassword page', () => { describe('account has recovery key', () => { const accountWithRecoveryKey = { resetPasswordStatus: jest.fn().mockResolvedValue(true), - completeResetPassword: jest.fn().mockResolvedValue(true), + completeResetPassword: jest.fn().mockResolvedValue(MOCK_RESET_DATA), hasRecoveryKey: jest.fn().mockResolvedValue(true), } as unknown as Account; diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx index 4975829e638..a80254c7594 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx @@ -175,7 +175,7 @@ const CompleteResetPassword = ({ // how account password hashing works previously. const emailToUse = emailToHashWith || email; - await account.completeResetPassword( + const accountResetData = await account.completeResetPassword( token, code, emailToUse, @@ -196,8 +196,22 @@ const CompleteResetPassword = ({ let hardNavigate = false; switch (integration.type) { + // NOTE: SyncBasic check is temporary until we implement codes + // See https://docs.google.com/document/d/1K4AD69QgfOCZwFLp7rUcMOkOTslbLCh7jjSdR9zpAkk/edit#heading=h.kkt4eylho93t case IntegrationType.SyncDesktop: - notifyFirefoxOfLogin(account, sessionIsVerified); + case IntegrationType.SyncBasic: + notifyFirefoxOfLogin( + { + authAt: accountResetData.authAt, + email, + keyFetchToken: accountResetData.keyFetchToken, + sessionToken: accountResetData.sessionToken, + uid: accountResetData.uid, + unwrapBKey: accountResetData.unwrapBKey, + verified: accountResetData.verified, + }, + sessionIsVerified + ); break; case IntegrationType.OAuth: if ( diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx index 2af8851cb1e..07999390052 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx @@ -9,7 +9,13 @@ import CompleteResetPassword from '.'; import LinkValidator from '../../../components/LinkValidator'; import { StorageData, UrlQueryData } from '../../../lib/model-data'; import { Account, AppContext } from '../../../models'; -import { mockAppContext, MOCK_ACCOUNT } from '../../../models/mocks'; +import { + mockAppContext, + MOCK_ACCOUNT, + createHistoryWithQuery, + renderWithRouter, + createAppContext, +} from '../../../models/mocks'; import { CompleteResetPasswordLink } from '../../../models/reset-password/verification'; import { ReachRouterWindow } from '../../../lib/window'; @@ -49,6 +55,14 @@ export const paramsWithMissingToken = { token: '', }; +export const MOCK_RESET_DATA = { + authAt: 12345, + keyFetchToken: 'keyFetchToken', + sessionToken: 'sessionToken', + unwrapBKey: 'unwrapBKey', + verified: true, +}; + export function mockUrlQueryData( params: Record = mockCompleteResetPasswordParams ) { @@ -69,38 +83,37 @@ class StorageDataMock extends StorageData { } } -export const Subject = ({ - account, - params, -}: { - account: Account; - params?: Record; -}) => { +const route = '/complete_reset_password'; +export const renderSubject = ( + account: Account, + params?: Record +) => { const windowWrapper = new ReachRouterWindow(); const urlQueryData = mockUrlQueryData(params); - - return ( - { + return new CompleteResetPasswordLink(urlQueryData); + }} > - - { - return new CompleteResetPasswordLink(urlQueryData); - }} - > - {({ setLinkStatus, params }) => ( - - )} - - - + {({ setLinkStatus, params }) => ( + + )} + , + { + route, + history, + }, + mockAppContext({ + ...createAppContext(history), + ...(account && { account }), + storageData: new StorageDataMock(windowWrapper), + }) ); }; diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx index f57a9d2cf3a..3017752daf7 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx @@ -3,16 +3,22 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/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_EMAIL, MOCK_PASSWORD_FORGOT_TOKEN } from './mocks'; import { REACT_ENTRYPOINT } from '../../../constants'; -import { LocationProvider } from '@reach/router'; -import { Account, AppContext } from '../../../models'; -import { mockAppContext, MOCK_ACCOUNT } from '../../../models/mocks'; +import { Account } from '../../../models'; +import { + mockAppContext, + MOCK_ACCOUNT, + createAppContext, + renderWithRouter, + createHistoryWithQuery, +} from '../../../models/mocks'; import { usePageViewEvent, logViewEvent } from '../../../lib/metrics'; +import { MozServices } from '../../../lib/types'; jest.mock('../../../lib/metrics', () => ({ logViewEvent: jest.fn(), @@ -21,15 +27,21 @@ jest.mock('../../../lib/metrics', () => ({ const account = MOCK_ACCOUNT as unknown as Account; -function renderWithAccount(account: Account) { - render( - - - - - +const route = '/confirm_reset_password'; +const renderWithHistory = (ui: any, queryParams = '', account?: Account) => { + const history = createHistoryWithQuery(route, queryParams); + return renderWithRouter( + ui, + { + route, + history, + }, + mockAppContext({ + ...createAppContext(history), + ...(account && { account }), + }) ); -} +}; const mockNavigate = jest.fn(); jest.mock('@reach/router', () => ({ @@ -53,7 +65,7 @@ describe('ConfirmResetPassword page', () => { // }); it('renders as expected', () => { - render(); + renderWithHistory(); // testAllL10n(screen, bundle); @@ -72,8 +84,8 @@ describe('ConfirmResetPassword page', () => { }); it('sends a new email when clicking on resend button', async () => { - renderWithAccount(account); - account.resendResetPassword = jest.fn().mockResolvedValue(''); + renderWithHistory(, '', account); + account.resetPassword = jest.fn().mockResolvedValue(''); const resendEmailButton = screen.getByRole('button', { name: 'Not in inbox or spam folder? Resend', @@ -81,7 +93,12 @@ describe('ConfirmResetPassword page', () => { fireEvent.click(resendEmailButton); - await waitFor(() => expect(account.resendResetPassword).toHaveBeenCalled()); + await waitFor(() => + expect(account.resetPassword).toHaveBeenCalledWith( + MOCK_EMAIL, + MozServices.Default + ) + ); expect(logViewEvent).toHaveBeenCalledWith( 'confirm-reset-password', 'resend', @@ -90,12 +107,12 @@ describe('ConfirmResetPassword page', () => { }); it('emits the expected metrics on render', async () => { - render(); + renderWithHistory(); expect(usePageViewEvent).toHaveBeenCalledWith(viewName, REACT_ENTRYPOINT); }); it('renders a "Remember your password?" link', () => { - render(); + renderWithHistory(); expect( screen.getByRole('link', { name: 'Remember your password? Sign in' }) ).toBeInTheDocument(); diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx index 45082fe5e6b..390e01f6b85 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx @@ -7,7 +7,7 @@ import { RouteComponentProps, useLocation, useNavigate } from '@reach/router'; import { POLLING_INTERVAL_MS, REACT_ENTRYPOINT } from '../../../constants'; import { usePageViewEvent, logViewEvent } from '../../../lib/metrics'; import { ResendStatus } from '../../../lib/types'; -import { useAccount, useInterval } from '../../../models'; +import { CreateRelier, useAccount, useInterval } from '../../../models'; import AppLayout from '../../../components/AppLayout'; import ConfirmWithLink, { ConfirmWithLinkPageStrings, @@ -24,6 +24,8 @@ export type ConfirmResetPasswordLocationState = { const ConfirmResetPassword = (_: RouteComponentProps) => { usePageViewEvent(viewName, REACT_ENTRYPOINT); + const relier = CreateRelier(); + const serviceName = relier.getServiceName(); const navigate = useNavigate(); let { state } = useLocation(); @@ -70,7 +72,7 @@ const ConfirmResetPassword = (_: RouteComponentProps) => { const resendEmailHandler = async () => { try { - const result = await account.resendResetPassword(email); + const result = await account.resetPassword(email, serviceName); logViewEvent(viewName, 'resend', REACT_ENTRYPOINT); setCurrentPasswordForgotToken(result.passwordForgotToken); setResendStatus(ResendStatus['sent']); diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx index 2d93d181b3e..3ad49eba485 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx @@ -13,7 +13,7 @@ import ResetPassword, { viewName } from '.'; import { REACT_ENTRYPOINT } from '../../constants'; import { MOCK_ACCOUNT, mockAppContext } from '../../models/mocks'; -import { Account, AppContext } from '../../models'; +import { Account } from '../../models'; import { AuthUiErrorNos } from '../../lib/auth-errors/auth-errors'; import { typeByLabelText } from '../../lib/test-utils'; import { @@ -21,6 +21,7 @@ import { createHistoryWithQuery, renderWithRouter, } from '../../models/mocks'; +import { MozServices } from '../../lib/types'; const mockLogViewEvent = jest.fn(); const mockLogPageViewEvent = jest.fn(); @@ -148,7 +149,8 @@ describe('PageResetPassword', () => { }); expect(account.resetPassword).toHaveBeenCalledWith( - MOCK_ACCOUNT.primaryEmail.email + MOCK_ACCOUNT.primaryEmail.email, + MozServices.Default ); expect(mockNavigate).toHaveBeenCalledWith( diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.tsx index 0fb5fc9fc7b..c59ab29f51c 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/index.tsx @@ -100,7 +100,7 @@ const ResetPassword = ({ async (email: string) => { try { clearError(); - const result = await account.resetPassword(email); + const result = await account.resetPassword(email, serviceName); navigateToConfirmPwReset({ passwordForgotToken: result.passwordForgotToken, email,