Skip to content

Commit

Permalink
feat(react): Send fxaLogin webchannel message conditionally after pas…
Browse files Browse the repository at this point in the history
…sword reset

Because:
* The browser should be be notified when a PW reset occurs on a verified account through the Sync flow, as this logs the user in

This commit:
* Conditionally runs the fxaLogin command with required account data after a password reset with or without recovery key, when the flow is sync
* Receives other values off the account reset call needed for webchannel account data
* Temporarily checks for SyncBasic and SyncDesktop flows to run the check because we don't need to port over context in local storage logic (which would account for SyncDesktop only), since we are switching to codes soon
* Sends up `service` with the email request so we can append the param and check when link is received (causing the  SyncBasic integration)
* Removes resendResetPassword and calls resetPassword where needed since the code was duplicated

Fixes FXA-7172
  • Loading branch information
LZoog committed Jun 7, 2023
1 parent 610b7bf commit e98cfd6
Show file tree
Hide file tree
Showing 17 changed files with 321 additions and 258 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@ export class AccountResetPayload {

@Field({ nullable: true })
public keyFetchToken?: string;

@Field({ nullable: true })
public unwrapBKey?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,25 +22,31 @@ jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
}));

function renderLinkExpiredResetPasswordWithAccount(account: Account) {
render(
<AppContext.Provider value={mockAppContext({ account })}>
<LocationProvider>
<LinkExpiredResetPassword {...{ email, viewName }} />
</LocationProvider>
</AppContext.Provider>
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 = <LinkExpiredResetPassword {...{ email, viewName }} />;

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',
Expand All @@ -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',
});
Expand All @@ -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',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,14 +20,16 @@ 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>(
ResendStatus['not sent']
);

const resendResetPasswordLink = async () => {
try {
await account.resendResetPassword(email);
await account.resetPassword(email, serviceName);
logViewEvent(viewName, 'resend', REACT_ENTRYPOINT);
setResendStatus(ResendStatus['sent']);
} catch (e) {
Expand Down
109 changes: 32 additions & 77 deletions packages/fxa-settings/src/lib/channels/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountData> = {};
// 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<keyof FxALoginRequest> = [
'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
Expand All @@ -98,6 +54,5 @@ export function notifyFirefoxOfLogin(
// if (loginData.uid !== uidOfLoginNotification) {
// uidOfLoginNotification = loginData.uid;

// send web channel LOGIN command with loginData
// }
firefox.fxaLogin(accountData);
}
74 changes: 25 additions & 49 deletions packages/fxa-settings/src/models/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -538,7 +540,10 @@ export class Account implements AccountData {
});
}

async resetPassword(email: string): Promise<PasswordForgotSendCodePayload> {
async resetPassword(
email: string,
service?: string
): Promise<PasswordForgotSendCodePayload> {
try {
const result = await this.apolloClient.mutate({
mutation: gql`
Expand All @@ -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) {
Expand Down Expand Up @@ -614,51 +629,6 @@ export class Account implements AccountData {
}
}

async resendResetPassword(
email: string
): Promise<PasswordForgotSendCodePayload> {
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.
Expand Down Expand Up @@ -721,6 +691,10 @@ export class Account implements AccountData {
clientMutationId
sessionToken
uid
authAt
keyFetchToken
verified
unwrapBKey
}
}
`,
Expand All @@ -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]) {
Expand Down Expand Up @@ -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);
Expand All @@ -1293,5 +1268,6 @@ export class Account implements AccountData {
},
},
});
return data;
}
}
Loading

0 comments on commit e98cfd6

Please sign in to comment.