diff --git a/packages/functional-tests/lib/email.ts b/packages/functional-tests/lib/email.ts index 275cc50223..21e8de8e5e 100644 --- a/packages/functional-tests/lib/email.ts +++ b/packages/functional-tests/lib/email.ts @@ -31,6 +31,7 @@ export enum EmailType { newDeviceLogin, passwordChanged, passwordChangeRequired, + passwordForgotOtp, passwordReset, passwordResetAccountRecovery, passwordResetRequired, @@ -71,6 +72,7 @@ export enum EmailHeader { link = 'x-link', templateName = 'x-template-name', templateVersion = 'x-template-version', + resetPasswordCode = 'x-password-forgot-otp', } export class EmailClient { diff --git a/packages/functional-tests/pages/resetPasswordReact.ts b/packages/functional-tests/pages/resetPasswordReact.ts index 75c15df4f6..c12a841ff0 100644 --- a/packages/functional-tests/pages/resetPasswordReact.ts +++ b/packages/functional-tests/pages/resetPasswordReact.ts @@ -6,7 +6,12 @@ export class ResetPasswordReactPage extends BaseLayout { readonly path = ''; get resetPasswordHeading() { - return this.page.getByRole('heading', { name: /^Reset password/ }); + return ( + this.page + .getByRole('heading', { name: /^Reset password/ }) + // for password reset redesign, with resetPasswordWithCode flag + .or(this.page.getByRole('heading', { name: /^Password reset/ })) + ); } get emailTextbox() { @@ -14,17 +19,31 @@ export class ResetPasswordReactPage extends BaseLayout { } get beginResetButton() { - return this.page.getByRole('button', { name: 'Begin reset' }); + return ( + this.page + .getByRole('button', { name: 'Begin reset' }) + // for password reset redesign, with resetPasswordWithCode flag + .or( + this.page.getByRole('button', { name: 'Send me reset instructions' }) + ) + ); } - get resetEmailSentHeading() { - return this.page.getByRole('heading', { name: 'Reset email sent' }); + get confirmResetPasswordHeading() { + return ( + this.page + .getByRole('heading', { name: 'Reset email sent' }) + // for password reset redesign, with resetPasswordWithCode flag + .or(this.page.getByRole('heading', { name: 'Enter confirmation code' })) + ); } get resendButton() { - return this.page.getByRole('button', { - name: 'Not in inbox or spam folder? Resend', - }); + return this.page + .getByRole('button', { + name: 'Not in inbox or spam folder? Resend', + }) // for password reset redesign, with resetPasswordWithCode flag + .or(this.page.getByRole('button', { name: 'Resend code' })); } get statusBar() { diff --git a/packages/functional-tests/tests/key-stretching-v2/recoveryKey.spec.ts b/packages/functional-tests/tests/key-stretching-v2/recoveryKey.spec.ts index b1f7a7eec5..fcfc9f6424 100644 --- a/packages/functional-tests/tests/key-stretching-v2/recoveryKey.spec.ts +++ b/packages/functional-tests/tests/key-stretching-v2/recoveryKey.spec.ts @@ -52,6 +52,7 @@ test.describe('severity-2 #smoke', () => { page, target, pages: { + configPage, signinReact, signupReact, settings, @@ -60,6 +61,11 @@ test.describe('severity-2 #smoke', () => { }, testAccountTracker, }) => { + const config = await configPage.getConfig(); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); const { email, password } = testAccountTracker.generateAccountDetails(); await page.goto( `${target.contentServerUrl}/?showReactApp=true&forceExperiment=generalizedReactApp&forceExperimentGroup=react&${signup.query}` diff --git a/packages/functional-tests/tests/key-stretching-v2/resetPassword.spec.ts b/packages/functional-tests/tests/key-stretching-v2/resetPassword.spec.ts index d9db69b2c2..24b2152684 100644 --- a/packages/functional-tests/tests/key-stretching-v2/resetPassword.spec.ts +++ b/packages/functional-tests/tests/key-stretching-v2/resetPassword.spec.ts @@ -50,9 +50,20 @@ test.describe('severity-2 #smoke', () => { test(`signs up as v${signup.version} resets password as v${reset.version} and signs in as v${signin.version}`, async ({ page, target, - pages: { signinReact, signupReact, settings, resetPasswordReact }, + pages: { + configPage, + signinReact, + signupReact, + settings, + resetPasswordReact, + }, testAccountTracker, }) => { + const config = await configPage.getConfig(); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); const { email, password } = testAccountTracker.generateAccountDetails(); await page.goto( `${target.contentServerUrl}/?showReactApp=true&forceExperiment=generalizedReactApp&forceExperimentGroup=react&${signup.query}` diff --git a/packages/functional-tests/tests/misc/keyStretchingV2.spec.ts b/packages/functional-tests/tests/misc/keyStretchingV2.spec.ts index 85ff4726ca..f063388ccc 100644 --- a/packages/functional-tests/tests/misc/keyStretchingV2.spec.ts +++ b/packages/functional-tests/tests/misc/keyStretchingV2.spec.ts @@ -15,6 +15,7 @@ import { SignupReactPage } from '../../pages/signupReact'; import { TotpPage } from '../../pages/settings/totp'; import { getCode } from 'fxa-settings/src/lib/totp'; import { DeleteAccountPage } from '../../pages/settings/deleteAccount'; +import { ConfigPage } from '../../pages/config'; // Disable this check for these tests. We are holding assertion in shared functions due // to how test permutations work, and these setup falsely trips this rule. @@ -47,6 +48,7 @@ test.describe('key-stretching-v2', () => { recoveryKey: RecoveryKeyPage; totp: TotpPage; deleteAccount: DeleteAccountPage; + configPage: ConfigPage; }; // Helpers diff --git a/packages/functional-tests/tests/react-conversion/oauthResetPassword.spec.ts b/packages/functional-tests/tests/react-conversion/oauthResetPassword.spec.ts index f9d3b00a30..71afe745d9 100644 --- a/packages/functional-tests/tests/react-conversion/oauthResetPassword.spec.ts +++ b/packages/functional-tests/tests/react-conversion/oauthResetPassword.spec.ts @@ -17,6 +17,10 @@ test.describe('severity-1 #smoke', () => { test.beforeEach(async ({ pages: { configPage } }) => { const config = await configPage.getConfig(); test.skip(config.showReactApp.resetPasswordRoutes !== true); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); test.slow(); }); diff --git a/packages/functional-tests/tests/react-conversion/recoveryKey.spec.ts b/packages/functional-tests/tests/react-conversion/recoveryKey.spec.ts index adc36dfdf9..32e58dae4e 100644 --- a/packages/functional-tests/tests/react-conversion/recoveryKey.spec.ts +++ b/packages/functional-tests/tests/react-conversion/recoveryKey.spec.ts @@ -13,6 +13,10 @@ test.describe('severity-1 #smoke', () => { // Ensure that the react reset password route feature flag is enabled const config = await configPage.getConfig(); test.skip(config.showReactApp.resetPasswordRoutes !== true); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); test.slow(); }); @@ -64,7 +68,9 @@ test.describe('severity-1 #smoke', () => { await resetPasswordReact.goto(); await resetPasswordReact.fillOutEmailForm(credentials.email); - await expect(resetPasswordReact.resetEmailSentHeading).toBeVisible(); + await expect( + resetPasswordReact.confirmResetPasswordHeading + ).toBeVisible(); const link = await target.emailClient.waitForEmail( credentials.email, diff --git a/packages/functional-tests/tests/react-conversion/resetPassword.spec.ts b/packages/functional-tests/tests/react-conversion/resetPassword.spec.ts index 4151e86d1a..b02448b7d9 100644 --- a/packages/functional-tests/tests/react-conversion/resetPassword.spec.ts +++ b/packages/functional-tests/tests/react-conversion/resetPassword.spec.ts @@ -9,9 +9,12 @@ import { ResetPasswordReactPage } from '../../pages/resetPasswordReact'; test.describe('severity-1 #smoke', () => { test.describe('reset password react', () => { test.beforeEach(async ({ pages: { configPage } }) => { - // Ensure that the feature flag is enabled const config = await configPage.getConfig(); test.skip(config.showReactApp.resetPasswordRoutes !== true); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); test.slow(); }); diff --git a/packages/functional-tests/tests/react-conversion/syncV3ResetPassword.spec.ts b/packages/functional-tests/tests/react-conversion/syncV3ResetPassword.spec.ts index 35387f7dea..9897fec737 100644 --- a/packages/functional-tests/tests/react-conversion/syncV3ResetPassword.spec.ts +++ b/packages/functional-tests/tests/react-conversion/syncV3ResetPassword.spec.ts @@ -9,10 +9,13 @@ import { getReactFeatureFlagUrl } from '../../lib/react-flag'; test.describe('severity-1 #smoke', () => { test.describe('Firefox Desktop Sync v3 reset password react', () => { test.beforeEach(async ({ pages: { configPage } }) => { - test.slow(); - // Ensure that the feature flag is enabled const config = await configPage.getConfig(); test.skip(config.showReactApp.resetPasswordRoutes !== true); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); + test.slow(); }); test('reset pw for sync user', async ({ diff --git a/packages/functional-tests/tests/settings/changePassword.spec.ts b/packages/functional-tests/tests/settings/changePassword.spec.ts index 77326c076a..5ef7defc59 100644 --- a/packages/functional-tests/tests/settings/changePassword.spec.ts +++ b/packages/functional-tests/tests/settings/changePassword.spec.ts @@ -160,9 +160,21 @@ test.describe('severity-1 #smoke', () => { test('reset password via settings works', async ({ target, - pages: { page, login, settings, changePassword, resetPasswordReact }, + pages: { + configPage, + page, + login, + settings, + changePassword, + resetPasswordReact, + }, testAccountTracker, }) => { + const config = await configPage.getConfig(); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); test.slow(); await signInAccount(target, page, login, testAccountTracker); diff --git a/packages/functional-tests/tests/signin/signIn.spec.ts b/packages/functional-tests/tests/signin/signIn.spec.ts index 98242b18b6..c6fb42c61d 100644 --- a/packages/functional-tests/tests/signin/signIn.spec.ts +++ b/packages/functional-tests/tests/signin/signIn.spec.ts @@ -12,9 +12,15 @@ test.describe('severity-2 #smoke', () => { test('signin verified with incorrect password, click `forgot password?`', async ({ target, page, - pages: { login, resetPassword }, + pages: { configPage, login, resetPassword }, testAccountTracker, }) => { + const config = await configPage.getConfig(); + test.fixme( + config.featureFlags.resetPasswordWithCode === true, + 'see FXA-9612' + ); + const credentials = await testAccountTracker.signUp(); await page.goto(target.contentServerUrl); diff --git a/packages/fxa-auth-server/config/dev.json b/packages/fxa-auth-server/config/dev.json index 79d5d98c50..3213c2bfe4 100644 --- a/packages/fxa-auth-server/config/dev.json +++ b/packages/fxa-auth-server/config/dev.json @@ -467,5 +467,8 @@ "subscriptionAccountReminders": { "firstInterval": "5s", "secondInterval": "10s" + }, + "passwordForgotOtp": { + "enabled": true } } diff --git a/packages/fxa-auth-server/lib/routes/password.ts b/packages/fxa-auth-server/lib/routes/password.ts index 1cf318e49a..6537259dc1 100644 --- a/packages/fxa-auth-server/lib/routes/password.ts +++ b/packages/fxa-auth-server/lib/routes/password.ts @@ -700,8 +700,10 @@ module.exports = function ( statsd.increment('otp.passwordForgot.verified'); return { - token: passwordForgotToken.data, code: passwordForgotToken.passCode, + emailToHashWith: passwordForgotToken.email, + token: passwordForgotToken.data, + uid: passwordForgotToken.uid, }; }, }, diff --git a/packages/fxa-auth-server/lib/senders/email.js b/packages/fxa-auth-server/lib/senders/email.js index d0b6dfc376..1cd7632971 100644 --- a/packages/fxa-auth-server/lib/senders/email.js +++ b/packages/fxa-auth-server/lib/senders/email.js @@ -1129,20 +1129,14 @@ module.exports = function (log, config, bounces) { const templateName = 'passwordForgotOtp'; const code = message.code; - const links = this._generateLinks( - this.initiatePasswordChangeUrl, - message, - {}, - templateName - ); + const links = this._generateLinks(undefined, message, {}, templateName); const [time, date] = this._constructLocalTimeString( message.timeZone, message.acceptLanguage ); const headers = { - 'X-Password-Forgot-OTP': code, - 'X-Link': links.passwordChangeLink, + 'X-Password-Forgot-Otp': code, }; return this.send({ @@ -1154,8 +1148,6 @@ module.exports = function (log, config, bounces) { date, device: this._formatUserAgentInfo(message), email: message.email, - passwordChangeLink: links.passwordChangeLink, - passwordChangeLinkAttributes: links.passwordChangeLinkAttributes, privacyUrl: links.privacyUrl, supportLinkAttributes: links.supportLinkAttributes, supportUrl: links.supportUrl, diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.mjml b/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.mjml index 5b3c06f9f6..b41b594200 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.mjml +++ b/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.mjml @@ -38,7 +38,7 @@ can obtain one at http://mozilla.org/MPL/2.0/. %> - <%- include('/partials/automatedEmailChangePassword/index.mjml') %> + <%- include('/partials/automatedEmailNoAction/index.mjml') %> diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.stories.ts b/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.stories.ts index 406dd834b7..c9f9193e46 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.stories.ts +++ b/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.stories.ts @@ -16,7 +16,6 @@ const createStory = storyWithProps( { ...MOCK_USER_INFO, code: '96318398', - passwordChangeLink: 'http://localhost:3030/settings/change_password', } ); diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.txt b/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.txt index febbc3ae3b..a8f1fb704c 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.txt +++ b/packages/fxa-auth-server/lib/senders/emails/templates/passwordForgotOtp/index.txt @@ -10,4 +10,4 @@ password-forgot-otp-code = "If yes, here is your confirmation code to proceed:" password-forgot-otp-expiry-notice = "This code expires in 10 minutes." -<%- include('/partials/automatedEmailChangePassword/index.txt') %> +<%- include('/partials/automatedEmailNoAction/index.txt') %> diff --git a/packages/fxa-auth-server/test/local/senders/emails.ts b/packages/fxa-auth-server/test/local/senders/emails.ts index 28e49f216a..1c185c5db4 100644 --- a/packages/fxa-auth-server/test/local/senders/emails.ts +++ b/packages/fxa-auth-server/test/local/senders/emails.ts @@ -898,7 +898,7 @@ const TESTS: [string, any, Record?][] = [ ['passwordForgotOtpEmail', new Map([ ['subject', { test: 'equal', expected: 'Forgot your password?' }], ['headers', new Map([ - ['X-Password-Forgot-OTP', { test: 'equal', expected: MESSAGE.code}], + ['X-Password-Forgot-Otp', { test: 'equal', expected: MESSAGE.code}], ['X-SES-MESSAGE-TAGS', { test: 'equal', expected: sesMessageTagsHeaderValue('passwordForgotOtp') }], ['X-Template-Name', { test: 'equal', expected: 'passwordForgotOtp' }], ['X-Template-Version', { test: 'equal', expected: TEMPLATE_VERSIONS.passwordForgotOtp }], @@ -908,14 +908,12 @@ const TESTS: [string, any, Record?][] = [ { test: 'include', expected: 'We received a request for a password change on your Mozilla account from:' }, { test: 'include', expected: MESSAGE.code }, { test: 'include', expected: `${MESSAGE.device.uaBrowser} on ${MESSAGE.device.uaOS} ${MESSAGE.device.uaOSVersion}` }, - { test: 'include', expected: 'change your password' }, ]], ['text', [ { test: 'include', expected: 'Forgot your password?' }, { test: 'include', expected: 'We received a request for a password change on your Mozilla account from:' }, { test: 'include', expected: MESSAGE.code }, { test: 'include', expected: `${MESSAGE.device.uaBrowser} on ${MESSAGE.device.uaOS} ${MESSAGE.device.uaOSVersion}` }, - { test: 'include', expected: 'change your password' }, ]], ])], diff --git a/packages/fxa-auth-server/test/mail_helper.js b/packages/fxa-auth-server/test/mail_helper.js index db6f47d836..eb840bdb93 100644 --- a/packages/fxa-auth-server/test/mail_helper.js +++ b/packages/fxa-auth-server/test/mail_helper.js @@ -44,6 +44,7 @@ module.exports = (printLogs) => { const vc = mail.headers['x-verify-code']; const vsc = mail.headers['x-verify-short-code']; const sc = mail.headers['x-signin-verify-code']; + const rpc = mail.headers['x-password-forgot-otp']; const template = mail.headers['x-template-name']; // Workaround because the email service wraps this header in `< >`. @@ -62,6 +63,8 @@ module.exports = (printLogs) => { } else if (uc) { console.log('\x1B[36mUnblock code:', uc, '\x1B[39m'); console.log('\x1B[36mReport link:', rul, '\x1B[39m'); + } else if (rpc) { + console.log('\x1B[36mReset password Otp:', rpc, '\x1B[39m'); } else if (TEMPLATES_WITH_NO_CODE.has(template)) { console.log(`Notification email: ${template}`); } else { diff --git a/packages/fxa-content-server/server/config/local.json-dist b/packages/fxa-content-server/server/config/local.json-dist index fcc3c86d99..d89daeb5a7 100644 --- a/packages/fxa-content-server/server/config/local.json-dist +++ b/packages/fxa-content-server/server/config/local.json-dist @@ -64,6 +64,6 @@ }, "featureFlags": { "sendFxAStatusOnSettings": true, - "resetPasswordWithCode": false + "resetPasswordWithCode": true } } diff --git a/packages/fxa-content-server/server/lib/routes/get-index.js b/packages/fxa-content-server/server/lib/routes/get-index.js index 334508cedc..84ae3bd5a8 100644 --- a/packages/fxa-content-server/server/lib/routes/get-index.js +++ b/packages/fxa-content-server/server/lib/routes/get-index.js @@ -52,6 +52,9 @@ module.exports = function (config) { const FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS = config.get( 'featureFlags.sendFxAStatusOnSettings' ); + const FEATURE_FLAGS_RESET_PWD_WITH_CODE = config.get( + 'featureFlags.resetPasswordWithCode' + ); const GLEAN_ENABLED = config.get('glean.enabled'); const GLEAN_APPLICATION_ID = config.get('glean.applicationId'); const GLEAN_UPLOAD_ENABLED = config.get('glean.uploadEnabled'); @@ -110,6 +113,7 @@ module.exports = function (config) { brandMessagingMode: BRAND_MESSAGING_MODE, featureFlags: { sendFxAStatusOnSettings: FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS, + resetPasswordWithCode: FEATURE_FLAGS_RESET_PWD_WITH_CODE, }, glean: { // feature toggle diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 7f2e824811..187531d0e5 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -76,6 +76,10 @@ 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'; const Settings = lazy(() => import('../Settings')); @@ -248,6 +252,7 @@ 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; @@ -273,41 +278,68 @@ const AuthAndAccountSetupRoutes = ({ /> {/* Reset password */} - { - return CreateCompleteResetPasswordLink(); - }} - {...{ integration }} - > - {({ setLinkStatus, linkModel }) => ( - + + + + + - )} - - - - - + + ) : ( + <> + + + + { + return CreateCompleteResetPasswordLink(); + }} + {...{ integration }} + > + {({ setLinkStatus, linkModel }) => ( + + )} + + + + )} + { - const story = () => ( - - - - ); - return story; -}; - -export const Default = storyWithProps({}); - -export const WithForceAuth = storyWithProps({ forceAuth: true }); +export const Default = () => ( + + + +); diff --git a/packages/fxa-settings/src/components/LinkRememberPassword/index.test.tsx b/packages/fxa-settings/src/components/LinkRememberPassword/index.test.tsx index 600dbd5e51..77cd8691d2 100644 --- a/packages/fxa-settings/src/components/LinkRememberPassword/index.test.tsx +++ b/packages/fxa-settings/src/components/LinkRememberPassword/index.test.tsx @@ -3,13 +3,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from 'react'; -import { fireEvent, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import LinkRememberPassword from '.'; import { MOCK_ACCOUNT } from '../../models/mocks'; import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils'; import { FluentBundle } from '@fluent/bundle'; -import { MOCK_CLIENT_ID } from '../../pages/mocks'; +import { MOCK_CLIENT_ID, MOCK_EMAIL } from '../../pages/mocks'; import { LocationProvider } from '@reach/router'; const mockLocation = () => { @@ -23,12 +23,9 @@ jest.mock('@reach/router', () => ({ useLocation: () => mockLocation(), })); -const Subject = ({ - email = MOCK_ACCOUNT.primaryEmail.email, - forceAuth = false, -}) => ( +const Subject = ({ email = MOCK_ACCOUNT.primaryEmail.email }) => ( - + ); @@ -38,38 +35,24 @@ describe('LinkRememberPassword', () => { bundle = await getFtlBundle('settings'); }); - it('renders', () => { + it('renders as expected', () => { renderWithLocalizationProvider(); testAllL10n(screen, bundle); - expect( - screen.getByRole('link', { name: 'Remember your password? Sign in' }) - ).toBeInTheDocument(); + expect(screen.getByText('Remember your password?')).toBeVisible(); + expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible(); }); - it('links to signin if not forceAuth, appends parameters', () => { + it('links to signin and appends parameters', async () => { renderWithLocalizationProvider(); const rememberPasswordLink = screen.getByRole('link', { - name: 'Remember your password? Sign in', + name: 'Sign in', }); - fireEvent.click(rememberPasswordLink); expect(rememberPasswordLink).toHaveAttribute( 'href', - '/signin?client_id=123&email=johndope%40example.com' - ); - }); - - it('links to force_auth if forceAuth is true, appends parameters', () => { - renderWithLocalizationProvider(); - - const rememberPasswordLink = screen.getByRole('link', { - name: 'Remember your password? Sign in', - }); - expect(rememberPasswordLink).toHaveAttribute( - 'href', - '/force_auth?client_id=123&email=johndope%40example.com' + `/signin?client_id=123&email=${encodeURIComponent(MOCK_EMAIL)}` ); }); }); diff --git a/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx b/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx index e29b71a1fd..9c771c8ed3 100644 --- a/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx +++ b/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx @@ -5,31 +5,33 @@ import React from 'react'; import { FtlMsg } from 'fxa-react/lib/utils'; import { useLocation } from '@reach/router'; +import { isEmailValid } from 'fxa-shared/email/helpers'; export type LinkRememberPasswordProps = { email?: string; forceAuth?: boolean; }; -const LinkRememberPassword = ({ - email, - forceAuth = false, -}: LinkRememberPasswordProps) => { +const LinkRememberPassword = ({ email }: LinkRememberPasswordProps) => { const location = useLocation(); const params = new URLSearchParams(location.search); - if (email) { + let linkHref: string; + if (email && isEmailValid(email)) { params.set('email', email); + linkHref = `/signin?${params}`; + } else { + linkHref = params.size > 0 ? `/?${params}` : '/'; } - const linkHref = `${ - forceAuth ? '/force_auth' : '/signin' - }?${params.toString()}`; return ( -
- - {/* TODO: use Link component once signin is Reactified */} - - Remember your password? Sign in + diff --git a/packages/fxa-settings/src/components/TotpInputGroup/index.tsx b/packages/fxa-settings/src/components/TotpInputGroup/index.tsx index 94c729396b..386bfc5616 100644 --- a/packages/fxa-settings/src/components/TotpInputGroup/index.tsx +++ b/packages/fxa-settings/src/components/TotpInputGroup/index.tsx @@ -140,6 +140,7 @@ export const TotpInputGroup = ({ e: React.ClipboardEvent, index: number ) => { + setErrorMessage(''); let currentIndex = index; const currentCodeArray = [...codeArray]; const clipboardText = e.clipboardData.getData('text'); @@ -237,7 +238,6 @@ export const TotpInputGroup = ({
{ return ( -
+
\ No newline at end of file diff --git a/packages/fxa-settings/src/components/images/index.stories.tsx b/packages/fxa-settings/src/components/images/index.stories.tsx index afd50820c7..d951079d4c 100644 --- a/packages/fxa-settings/src/components/images/index.stories.tsx +++ b/packages/fxa-settings/src/components/images/index.stories.tsx @@ -48,3 +48,5 @@ export const Lock = () => ; export const Key = () => ; export const Lightbulb = () => ; + +export const EmailCode = () => ; diff --git a/packages/fxa-settings/src/components/images/index.tsx b/packages/fxa-settings/src/components/images/index.tsx index 19d73d1fdd..e8e292f61b 100644 --- a/packages/fxa-settings/src/components/images/index.tsx +++ b/packages/fxa-settings/src/components/images/index.tsx @@ -8,6 +8,7 @@ import { ReactComponent as SecurityShield } from './graphic_security_shield.svg' import { ReactComponent as Key } from './graphic_key.svg'; import { ReactComponent as Lock } from './graphic_lock.svg'; import { ReactComponent as Lightbulb } from './graphic_lightbulb.svg'; +import { ReactComponent as EmailCode } from './graphic_email_code.svg'; import { FtlMsg } from 'fxa-react/lib/utils'; @@ -143,3 +144,12 @@ export const LightbulbImage = ({ className, ariaHidden }: ImageProps) => ( {...{ className, ariaHidden }} /> ); + +export const EmailCodeImage = ({ className, ariaHidden }: ImageProps) => ( + +); diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 799fba23b9..01a4349ae7 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -1199,16 +1199,6 @@ export class Account implements AccountData { return recoveryKey; } - // Not currently in use - enable in FXA-7894 after FXA-7400 is completed - // async getRecoveryKeyHint() { - // const recoveryKeyHint = await this.authClient.getRecoveryKeyHint( - // sessionToken()!, - // this.primaryEmail.email - // ); - - // return recoveryKeyHint; - // } - async updateRecoveryKeyHint(hint: string) { await this.withLoadingStatus( this.authClient.updateRecoveryKeyHint(sessionToken()!, hint) diff --git a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx index a2491610fc..e9823b6ffc 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryConfirmKey/index.tsx @@ -237,7 +237,7 @@ const AccountRecoveryConfirmKey = ({ warningMessageFtlId="account-recovery-confirm-key-warning-message" warningType="Note:" > - If you reset your password and don't have account recovery key saved, + 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). 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 a72731cfa8..e9b96f0122 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/AccountRecoveryResetPassword/index.test.tsx @@ -201,13 +201,13 @@ describe('AccountRecoveryResetPassword page', () => { const heading = await screen.findByRole('heading', { name: 'Create new password', }); - screen.getByLabelText('New password'); - screen.getByLabelText('Re-enter password'); - - screen.getByRole('button', { name: 'Reset password' }); - screen.getByRole('link', { - name: 'Remember your password? Sign in', - }); + 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(); }); 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 4ab8a1e042..9806bea07a 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx @@ -27,7 +27,7 @@ import { mockSession, renderWithRouter, } from '../../../models/mocks'; -import { MOCK_RESET_TOKEN } from '../../mocks'; +import { MOCK_EMAIL, MOCK_RESET_TOKEN } from '../../mocks'; // import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils'; // import { FluentBundle } from '@fluent/bundle'; @@ -139,9 +139,8 @@ describe('CompleteResetPassword page', () => { screen.getByLabelText('New password'); screen.getByLabelText('Re-enter password'); screen.getByRole('button', { name: 'Reset password' }); - screen.getByRole('link', { - name: 'Remember your password? Sign in', - }); + 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 () => { @@ -426,7 +425,11 @@ describe('CompleteResetPassword page', () => { render(, account, session); await enterPasswordAndSubmit(); expect(mockUseNavigateWithoutRerender).toHaveBeenCalledWith( - '/reset_password_verified?email=johndope%40example.com&emailToHashWith=johndope%40example.com&token=1111111111111111111111111111111111111111111111111111111111111111&code=11111111111111111111111111111111&uid=abc123', + `/reset_password_verified?email=${encodeURIComponent( + MOCK_EMAIL + )}&emailToHashWith=${encodeURIComponent( + MOCK_EMAIL + )}&token=1111111111111111111111111111111111111111111111111111111111111111&code=11111111111111111111111111111111&uid=abc123`, { replace: true, } 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 f0df70fa99..1c28361141 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx @@ -132,8 +132,7 @@ describe('ConfirmResetPassword page', () => { it('renders a "Remember your password?" link', () => { renderWithHistory(); - expect( - screen.getByRole('link', { name: 'Remember your password? Sign in' }) - ).toBeInTheDocument(); + expect(screen.getByText('Remember your password?')).toBeVisible(); + expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible(); }); }); diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx index 291eb0a321..d18628cbb7 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx @@ -105,9 +105,8 @@ describe('PageResetPassword', () => { // 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.getByRole('link', { name: 'Remember your password? Sign in' }) - ).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 () => { diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/container.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/container.tsx new file mode 100644 index 0000000000..9caea6e551 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/container.tsx @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState } from 'react'; +import { RouteComponentProps, useLocation, useNavigate } from '@reach/router'; +import base32Decode from 'base32-decode'; + +import { decryptRecoveryKeyData } from 'fxa-auth-client/lib/recoveryKey'; +import { getLocalizedErrorMessage } from '../../../lib/auth-errors/auth-errors'; +import { useAccount } from '../../../models'; +import { useFtlMsgResolver } from '../../../models/hooks'; + +import { + AccountRecoveryConfirmKeyContainerProps, + AccountRecoveryConfirmKeyLocationState, +} from './interfaces'; + +import AccountRecoveryConfirmKey from '.'; + +const AccountRecoveryConfirmKeyContainer = ({ + serviceName, +}: AccountRecoveryConfirmKeyContainerProps & RouteComponentProps) => { + const [errorMessage, setErrorMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const account = useAccount(); + const ftlMsgResolver = useFtlMsgResolver(); + const location = useLocation(); + const navigate = useNavigate(); + + const { + accountResetToken: previousAccountResetToken, + code, + email, + emailToHashWith, + estimatedSyncDeviceCount, + recoveryKeyExists, + token, + uid, + } = (location.state as AccountRecoveryConfirmKeyLocationState) || {}; + + // 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 [accountResetToken, setAccountResetToken] = useState( + previousAccountResetToken || '' + ); + + const getRecoveryBundleAndNavigate = async ( + fetchedAccountResetToken: string, + recoveryKey: string + ) => { + // TODO in FXA-9672: do not use Account model in reset password pages + const { recoveryData, recoveryKeyId } = await account.getRecoveryKeyBundle( + fetchedAccountResetToken, + recoveryKey, + uid + ); + + const decodedRecoveryKey = base32Decode(recoveryKey, 'Crockford'); + const uint8RecoveryKey = new Uint8Array(decodedRecoveryKey); + + const { kB } = await decryptRecoveryKeyData( + uint8RecoveryKey, + recoveryData, + uid + ); + + navigate(`/account_recovery_reset_password${window.location.search}`, { + state: { + accountResetToken: fetchedAccountResetToken, + email, + emailToHashWith, + estimatedSyncDeviceCount, + kB, + recoveryKeyId, + }, + }); + }; + + const verifyRecoveryKey = async (recoveryKey: string) => { + try { + let fetchedAccountResetToken = accountResetToken; + if (!fetchedAccountResetToken) { + // TODO in FXA-9672: do not use Account model in reset password pages + fetchedAccountResetToken = await account.passwordForgotVerifyCode( + token, + code, + true + ); + setAccountResetToken(fetchedAccountResetToken); + } + await getRecoveryBundleAndNavigate(fetchedAccountResetToken, recoveryKey); + } catch (error) { + const localizedBannerMessage = getLocalizedErrorMessage( + ftlMsgResolver, + error + ); + setIsSubmitting(false); + setErrorMessage(localizedBannerMessage); + } + }; + + return ( + + ); +}; + +export default AccountRecoveryConfirmKeyContainer; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/gql.ts b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/gql.ts new file mode 100644 index 0000000000..7b5ab67aa6 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/gql.ts @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { gql } from '@apollo/client'; + +export const GET_RECOVERY_BUNDLE_QUERY = gql` + query GetRecoveryKeyBundle($input: RecoveryKeyBundleInput!) { + getRecoveryKeyBundle(input: $input) { + recoveryData + } + } +`; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.stories.tsx new file mode 100644 index 0000000000..d6c56a43bb --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.stories.tsx @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import AccountRecoveryConfirmKey from '.'; +import { Meta } from '@storybook/react'; +import { withLocalization } from 'fxa-react/lib/storybooks'; +import { Subject } from './mocks'; + +export default { + title: 'Pages/ResetPasswordRedesign/AccountRecoveryConfirmKey', + component: AccountRecoveryConfirmKey, + decorators: [withLocalization], +} as Meta; + +export const Default = () => ; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.test.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.test.tsx new file mode 100644 index 0000000000..9e985714af --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.test.tsx @@ -0,0 +1,195 @@ +/* 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 { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { Subject } from './mocks'; +import { screen, waitFor } from '@testing-library/react'; +import { MozServices } from '../../../lib/types'; +import userEvent from '@testing-library/user-event'; +import { MOCK_RECOVERY_KEY } from '../../mocks'; +import GleanMetrics from '../../../lib/glean'; + +const mockVerifyRecoveryKey = jest.fn((_recoveryKey: string) => + Promise.resolve() +); + +jest.mock('../../../lib/glean', () => ({ + resetPassword: { + recoveryKeyView: jest.fn(), + recoveryKeySubmit: jest.fn(), + }, +})); + +describe('AccountRecoveryConfirmKey', () => { + beforeEach(() => { + (GleanMetrics.resetPassword.recoveryKeyView as jest.Mock).mockReset(); + (GleanMetrics.resetPassword.recoveryKeySubmit as jest.Mock).mockReset(); + mockVerifyRecoveryKey.mockClear(); + }); + + it('renders as expected', async () => { + renderWithLocalizationProvider(); + + 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 () => { + renderWithLocalizationProvider(); + await screen.findByText(`to continue to ${MozServices.Default}`); + }); + + it('renders non-default', async () => { + renderWithLocalizationProvider( + + ); + await screen.findByText(`to continue to Firefox Sync`); + }); + }); + + describe('submit', () => { + describe('success', () => { + it('with valid recovery key', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + // adding text in the field enables the submit button + await waitFor(() => + user.type( + screen.getByLabelText('Enter account recovery key'), + MOCK_RECOVERY_KEY + ) + ); + + const submitButton = screen.getByRole('button', { + name: 'Confirm account recovery key', + }); + expect(submitButton).toBeEnabled(); + await waitFor(() => user.click(submitButton)); + + expect(mockVerifyRecoveryKey).toHaveBeenCalled(); + expect( + GleanMetrics.resetPassword.recoveryKeySubmit + ).toHaveBeenCalledTimes(1); + }); + + it('with spaces in valid recovery key', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + const submitButton = screen.getByRole('button', { + name: 'Confirm account recovery key', + }); + + const input = screen.getByLabelText('Enter account recovery key'); + + const recoveryKeyWithSpaces = MOCK_RECOVERY_KEY.replace( + /(.{4})/g, + '$1 ' + ); + expect(recoveryKeyWithSpaces).toHaveLength(40); + + // adding text in the field enables the submit button + await waitFor(() => user.type(input, recoveryKeyWithSpaces)); + expect(submitButton).toBeEnabled(); + await waitFor(() => user.click(submitButton)); + + expect(mockVerifyRecoveryKey).toHaveBeenCalledTimes(1); + expect(mockVerifyRecoveryKey).toHaveBeenCalledWith(MOCK_RECOVERY_KEY); + }); + }); + + describe('errors', () => { + it('with an empty input', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider(); + const submitButton = screen.getByRole('button', { + name: 'Confirm account recovery key', + }); + expect(submitButton).toBeDisabled(); + + const input = screen.getByLabelText('Enter account recovery key'); + + // adding text in the field enables the submit button + await waitFor(() => user.type(input, 'a')); + expect(submitButton).not.toBeDisabled(); + }); + + it('with less than 32 characters', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + const submitButton = screen.getByRole('button', { + name: 'Confirm account recovery key', + }); + const input = screen.getByLabelText('Enter account recovery key'); + + // adding text in the field enables the submit button + await waitFor(() => user.type(input, MOCK_RECOVERY_KEY.slice(0, -1))); + expect(submitButton).toBeEnabled(); + + await waitFor(() => user.click(submitButton)); + + expect(mockVerifyRecoveryKey).not.toHaveBeenCalled(); + await screen.findByText('Invalid account recovery key'); + }); + + it('with more than 32 characters', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + const submitButton = screen.getByRole('button', { + name: 'Confirm account recovery key', + }); + expect(submitButton).toBeDisabled(); + + const input = screen.getByLabelText('Enter account recovery key'); + + // adding text in the field enables the submit button + await waitFor(() => user.type(input, `${MOCK_RECOVERY_KEY}abc`)); + expect(submitButton).toBeEnabled(); + await waitFor(() => user.click(submitButton)); + + await screen.findByText('Invalid account recovery key'); + expect(mockVerifyRecoveryKey).not.toHaveBeenCalled(); + }); + + it('with invalid Crockford base32', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + const submitButton = screen.getByRole('button', { + name: 'Confirm account recovery key', + }); + + const input = screen.getByLabelText('Enter account recovery key'); + await waitFor(() => user.type(input, `${MOCK_RECOVERY_KEY}L`.slice(1))); + expect(submitButton).toBeEnabled(); + await waitFor(() => user.click(submitButton)); + + await screen.findByText('Invalid account recovery key'); + expect(mockVerifyRecoveryKey).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.tsx new file mode 100644 index 0000000000..7871a30d4d --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/index.tsx @@ -0,0 +1,170 @@ +/* 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 React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { FtlMsg } from 'fxa-react/lib/utils'; +import { + AuthUiErrors, + getLocalizedErrorMessage, +} from '../../../lib/auth-errors/auth-errors'; +import GleanMetrics from '../../../lib/glean'; +import { isBase32Crockford } from '../../../lib/utilities'; +import { useFtlMsgResolver } from '../../../models/hooks'; + +import AppLayout from '../../../components/AppLayout'; +import Banner, { BannerType } from '../../../components/Banner'; +import CardHeader from '../../../components/CardHeader'; +import InputText from '../../../components/InputText'; +import WarningMessage from '../../../components/WarningMessage'; + +import { + AccountRecoveryConfirmKeyFormData, + AccountRecoveryConfirmKeyProps, +} from './interfaces'; + +const AccountRecoveryConfirmKey = ({ + accountResetToken, + code, + email, + emailToHashWith, + errorMessage, + estimatedSyncDeviceCount, + isSubmitting, + recoveryKeyExists, + serviceName, + setErrorMessage, + setIsSubmitting, + verifyRecoveryKey, + token, + uid, +}: AccountRecoveryConfirmKeyProps) => { + const ftlMsgResolver = useFtlMsgResolver(); + const location = useLocation(); + + const { getValues, handleSubmit, register, formState } = + useForm({ + mode: 'onChange', + criteriaMode: 'all', + defaultValues: { + recoveryKey: '', + }, + }); + + useEffect(() => { + GleanMetrics.resetPassword.recoveryKeyView(); + }, []); + + const onSubmit = () => { + setErrorMessage(''); + // 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 recoveryKey = getValues('recoveryKey').replace(/\s/g, ''); + + if (recoveryKey.length === 32 && isBase32Crockford(recoveryKey)) { + setIsSubmitting(true); + GleanMetrics.resetPassword.recoveryKeySubmit(); + verifyRecoveryKey(recoveryKey); + } else { + // if the submitted key does not match the expected format, + // abort before submitting to the auth server + const localizedErrorMessage = getLocalizedErrorMessage( + ftlMsgResolver, + AuthUiErrors.INVALID_RECOVERY_KEY + ); + setErrorMessage(localizedErrorMessage); + setIsSubmitting(false); + } + }; + + return ( + + + + {errorMessage && {errorMessage}} + + +

+ 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). + + +
+ + + + + + + +
+ + + + Don’t have an account recovery key? + + +
+ ); +}; + +export default AccountRecoveryConfirmKey; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/interfaces.ts b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/interfaces.ts new file mode 100644 index 0000000000..ae576722ef --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/interfaces.ts @@ -0,0 +1,34 @@ +/* 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 { MozServices } from '../../../lib/types'; + +export interface AccountRecoveryConfirmKeyFormData { + recoveryKey: string; +} + +export type AccountRecoveryConfirmKeyContainerProps = { + serviceName: MozServices; +}; + +export interface AccountRecoveryConfirmKeyLocationState { + code: string; + email: string; + estimatedSyncDeviceCount: number; + token: string; + uid: string; + accountResetToken?: string; + emailToHashWith?: string; + recoveryKeyExists?: boolean; +} + +export interface AccountRecoveryConfirmKeyProps + extends AccountRecoveryConfirmKeyLocationState { + errorMessage: string; + isSubmitting: boolean; + serviceName: MozServices; + setErrorMessage: React.Dispatch>; + setIsSubmitting: React.Dispatch>; + verifyRecoveryKey: (recoveryKey: string) => Promise; +} diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/mocks.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/mocks.tsx new file mode 100644 index 0000000000..ffd3da75b5 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/AccountRecoveryConfirmKey/mocks.tsx @@ -0,0 +1,45 @@ +/* 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 { MozServices } from '../../../lib/types'; +import { MOCK_EMAIL, MOCK_HEXSTRING_32, MOCK_UID } from '../../mocks'; +import AccountRecoveryConfirmKey from '.'; +import { LocationProvider } from '@reach/router'; +import { AccountRecoveryConfirmKeyProps } from './interfaces'; + +const mockVerifyRecoveryKey = (key: string) => Promise.resolve(); + +export const Subject = ({ + serviceName = MozServices.Default, + verifyRecoveryKey = mockVerifyRecoveryKey, +}: Partial) => { + const [errorMessage, setErrorMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const code = MOCK_HEXSTRING_32; + const email = MOCK_EMAIL; + const estimatedSyncDeviceCount = 2; + const token = MOCK_HEXSTRING_32; + const uid = MOCK_UID; + + return ( + + + + ); +}; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/container.tsx new file mode 100644 index 0000000000..e87e8cee0b --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/container.tsx @@ -0,0 +1,195 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { RouteComponentProps, useLocation, useNavigate } from '@reach/router'; + +import { useValidatedQueryParams } from '../../../lib/hooks/useValidate'; +import { + Integration, + useAccount, + useConfig, + useFtlMsgResolver, +} from '../../../models'; +import { KeyStretchExperiment } from '../../../models/experiments'; + +import CompleteResetPassword from '.'; +import { + AccountResetData, + CompleteResetPasswordLocationState, +} from './interfaces'; +import GleanMetrics from '../../../lib/glean'; +import firefox from '../../../lib/channels/firefox'; +import { getLocalizedErrorMessage } from '../../../lib/auth-errors/auth-errors'; +import { useState } from 'react'; + +// This component is used for both /complete_reset_password and /account_recovery_reset_password routes +// for easier maintenance + +const CompleteResetPasswordContainer = ({ + integration, +}: { + integration: Integration; +} & RouteComponentProps) => { + const keyStretchExperiment = useValidatedQueryParams(KeyStretchExperiment); + + const account = useAccount(); + const config = useConfig(); + const ftlMsgResolver = useFtlMsgResolver(); + const navigate = useNavigate(); + const location = useLocation(); + + const [errorMessage, setErrorMessage] = useState(''); + + const searchParams = location.search; + + if (!location.state) { + navigate(`/reset_password${searchParams}`); + } + + const { + code, + email, + token, + accountResetToken, + emailToHashWith, + kB, + recoveryKeyId, + } = location.state as CompleteResetPasswordLocationState; + + const hasConfirmedRecoveryKey = !!( + accountResetToken && + email && + kB && + recoveryKeyId + ); + + const isResetWithoutRecoveryKey = !!(code && token); + + const handleNavigationWithRecoveryKey = () => { + navigate(`/reset_password_with_recovery_key_verified${searchParams}`); + }; + + const handleNavigationWithoutRecoveryKey = () => { + navigate(`/reset_password_verified${searchParams}`, { + replace: true, + }); + }; + + const resetPasswordWithRecoveryKey = async ( + accountResetToken: string, + emailToUse: string, + kB: string, + newPassword: string, + recoveryKeyId: string + ) => { + const options = { + accountResetToken, + emailToHashWith: emailToUse, + password: newPassword, + recoveryKeyId, + kB, + }; + + // TODO in FXA-9672: do not use Account model in reset password pages + const accountResetData = await account.resetPasswordWithRecoveryKey( + options + ); + + return accountResetData; + }; + + const resetPasswordWithoutRecoveryKey = async ( + code: string, + emailToUse: string, + newPassword: string, + token: string + ) => { + // TODO in FXA-9672: do not use Account model in reset password pages + const accountResetData: AccountResetData = + await account.completeResetPassword( + keyStretchExperiment.queryParamModel.isV2(config), + token, + code, + emailToUse, + newPassword, + accountResetToken + ); + return accountResetData; + }; + + const notifyBrowserOfSignin = async (accountResetData: AccountResetData) => { + if (integration.isSync()) { + firefox.fxaLoginSignedInUser({ + authAt: accountResetData.authAt, + email, + keyFetchToken: accountResetData.keyFetchToken, + sessionToken: accountResetData.sessionToken, + uid: accountResetData.uid, + unwrapBKey: accountResetData.unwrapBKey, + verified: accountResetData.verified, + }); + } + }; + + const submitNewPassword = async (newPassword: string) => { + 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; + + if (hasConfirmedRecoveryKey) { + GleanMetrics.resetPassword.recoveryKeyCreatePasswordSubmit(); + const accountResetData = await resetPasswordWithRecoveryKey( + accountResetToken, + emailToUse, + kB, + newPassword, + recoveryKeyId + ); + // TODO add frontend Glean event for successful reset? + notifyBrowserOfSignin(accountResetData); + handleNavigationWithRecoveryKey(); + } else if (isResetWithoutRecoveryKey) { + GleanMetrics.resetPassword.createNewSubmit(); + const accountResetData = await resetPasswordWithoutRecoveryKey( + code, + emailToUse, + newPassword, + token + ); + // TODO add frontend Glean event for successful reset? + notifyBrowserOfSignin(accountResetData); + handleNavigationWithoutRecoveryKey(); + } + } catch (err) { + const localizedBannerMessage = getLocalizedErrorMessage( + ftlMsgResolver, + err + ); + setErrorMessage(localizedBannerMessage); + } + }; + + // handle the case where we don't have all data required + if (!(hasConfirmedRecoveryKey || isResetWithoutRecoveryKey)) { + navigate(`/reset_password${searchParams}`); + } + return ( + + ); +}; + +export default CompleteResetPasswordContainer; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.stories.tsx new file mode 100644 index 0000000000..19808f6d39 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.stories.tsx @@ -0,0 +1,23 @@ +/* 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 { withLocalization } from 'fxa-react/lib/storybooks'; +import { Subject } from './mocks'; +import CompleteResetPassword from '.'; + +export default { + title: 'Pages/ResetPasswordRedesign/CompleteResetPassword', + component: CompleteResetPassword, + decorators: [withLocalization], +} as Meta; + +export const DefaultNoRecoveryKey = () => ; + +export const WithConfirmedRecoveryKey = () => ( + +); + +export const UnknownRecoveryKeyStatus = () => ; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.test.tsx new file mode 100644 index 0000000000..8744873752 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.test.tsx @@ -0,0 +1,160 @@ +/* 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 { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { Subject } from './mocks'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MOCK_EMAIL, MOCK_PASSWORD } from '../../mocks'; +import GleanMetrics from '../../../lib/glean'; + +const mockSubmitNewPassword = jest.fn((newPassword: string) => + Promise.resolve() +); + +jest.mock('../../../lib/glean', () => ({ + __esModule: true, + default: { + resetPassword: { + createNewView: jest.fn(), + recoveryKeyCreatePasswordView: jest.fn(), + }, + }, +})); + +describe('CompleteResetPassword page', () => { + beforeEach(() => { + (GleanMetrics.resetPassword.createNewView as jest.Mock).mockClear(); + ( + GleanMetrics.resetPassword.recoveryKeyCreatePasswordView as jest.Mock + ).mockClear(); + mockSubmitNewPassword.mockClear(); + }); + + describe('default reset without recovery key', () => { + it('renders as expected', async () => { + renderWithLocalizationProvider(); + + await waitFor(() => + expect( + screen.getByRole('heading', { + name: 'Create new password', + }) + ).toBeVisible() + ); + expect( + screen.getByText( + '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.' + ) + ).toBeVisible(); + // no recovery key specific messaging + expect( + screen.queryByText( + '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.' + ) + ).not.toBeInTheDocument(); + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + 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(); + const link = screen.getByRole('link', { name: 'Sign in' }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute( + 'href', + `/signin?email=${encodeURIComponent(MOCK_EMAIL)}` + ); + }); + + it('sends the expected metrics on render', () => { + renderWithLocalizationProvider(); + expect(GleanMetrics.resetPassword.createNewView).toHaveBeenCalledTimes(1); + }); + }); + + describe('reset with account recovery key confirmed', () => { + it('renders as expected', async () => { + renderWithLocalizationProvider(); + + await waitFor(() => + expect( + screen.getByRole('heading', { + name: 'Create new password', + }) + ).toBeVisible() + ); + // recovery key specific messaging + expect( + screen.getByText( + '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.' + ) + ).toBeVisible(); + // no warning + expect( + screen.queryByText( + '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.' + ) + ).not.toBeInTheDocument(); + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + 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(); + const link = screen.getByRole('link', { name: 'Sign in' }); + expect(link).toBeVisible(); + expect(link).toHaveAttribute( + 'href', + `/signin?email=${encodeURIComponent(MOCK_EMAIL)}` + ); + }); + + it('sends the expected metrics on render', () => { + renderWithLocalizationProvider(); + expect( + GleanMetrics.resetPassword.recoveryKeyCreatePasswordView + ).toHaveBeenCalledTimes(1); + }); + }); + + it('handles submit with valid password', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + + await waitFor(() => + user.type(screen.getByLabelText('New password'), MOCK_PASSWORD) + ); + await waitFor(() => + user.type(screen.getByLabelText('Re-enter password'), MOCK_PASSWORD) + ); + const button = screen.getByRole('button', { name: 'Reset password' }); + expect(button).toBeEnabled(); + await waitFor(() => user.click(button)); + + expect(mockSubmitNewPassword).toHaveBeenCalledTimes(1); + expect(mockSubmitNewPassword).toHaveBeenCalledWith(MOCK_PASSWORD); + }); + + it('handles errors', async () => { + renderWithLocalizationProvider( + + ); + + await waitFor(() => + expect( + screen.getByRole('heading', { + name: 'Create new password', + }) + ).toBeVisible() + ); + expect(screen.getByText('Something went wrong')).toBeVisible(); + }); +}); diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.tsx new file mode 100644 index 0000000000..f1271311a6 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/index.tsx @@ -0,0 +1,134 @@ +/* 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, { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import GleanMetrics from '../../../lib/glean'; + +import AppLayout from '../../../components/AppLayout'; +import Banner, { BannerType } from '../../../components/Banner'; +import CardHeader from '../../../components/CardHeader'; +import FormPasswordWithBalloons from '../../../components/FormPasswordWithBalloons'; +import LinkRememberPassword from '../../../components/LinkRememberPassword'; +import WarningMessage from '../../../components/WarningMessage'; + +import { + CompleteResetPasswordFormData, + CompleteResetPasswordProps, +} from './interfaces'; +import { FtlMsg } from 'fxa-react/lib/utils'; +import { Link } from '@reach/router'; + +const CompleteResetPassword = ({ + email, + errorMessage, + hasConfirmedRecoveryKey, + locationState, + submitNewPassword, + searchParams, +}: CompleteResetPasswordProps) => { + useEffect(() => { + hasConfirmedRecoveryKey + ? GleanMetrics.resetPassword.recoveryKeyCreatePasswordView() + : GleanMetrics.resetPassword.createNewView(); + }, [hasConfirmedRecoveryKey]); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const { handleSubmit, register, getValues, errors, formState, trigger } = + useForm({ + mode: 'onTouched', + criteriaMode: 'all', + defaultValues: { + newPassword: '', + confirmPassword: '', + }, + }); + + const onSubmit = async () => { + setIsSubmitting(true); + await submitNewPassword(getValues('newPassword')); + setIsSubmitting(false); + }; + + return ( + + + + {!hasConfirmedRecoveryKey && + locationState.recoveryKeyExists === undefined && ( + + <> + +

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

+
+ {/* TODO add metrics to measure if users see and click on this link */} + + + Reset your password with your account recovery key. + + + +
+ )} + + {errorMessage && {errorMessage}} + + {hasConfirmedRecoveryKey ? ( + +

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

+
+ ) : ( + + 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. */} + +
+ +
+ +
+ ); +}; + +export default CompleteResetPassword; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/interfaces.ts new file mode 100644 index 0000000000..4326600437 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/interfaces.ts @@ -0,0 +1,54 @@ +/* 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 { Integration, OAuthIntegration } from '../../../models'; + +export interface CompleteResetPasswordFormData { + newPassword: string; + confirmPassword: string; +} + +export type CompleteResetPasswordLocationState = { + code: string; + email: string; + token: string; + uid: string; + accountResetToken?: string; + emailToHashWith?: string; + estimatedSyncDeviceCount?: number; + kB?: string; + recoveryKeyExists?: boolean; + recoveryKeyId?: string; +}; + +export type CompleteResetPasswordOAuthIntegration = Pick< + OAuthIntegration, + 'type' | 'data' | 'isSync' +>; + +type CompleteResetPasswordIntegration = + | Pick + | CompleteResetPasswordOAuthIntegration; + +export type CompleteResetPasswordContainerProps = { + integration: CompleteResetPasswordIntegration; +}; + +export interface CompleteResetPasswordProps { + email: string; + errorMessage: string; + locationState: CompleteResetPasswordLocationState; + submitNewPassword: (newPassword: string) => Promise; + hasConfirmedRecoveryKey?: boolean; + searchParams?: string; +} + +export type AccountResetData = { + authAt: number; + keyFetchToken: string; + sessionToken: string; + uid: string; + unwrapBKey: string; + verified: boolean; +}; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/mocks.tsx new file mode 100644 index 0000000000..00769cf182 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/CompleteResetPassword/mocks.tsx @@ -0,0 +1,49 @@ +/* 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 { MOCK_EMAIL, MOCK_HEXSTRING_32, MOCK_UID } from '../../mocks'; +import CompleteResetPassword from '.'; +import { LocationProvider } from '@reach/router'; +import { + CompleteResetPasswordLocationState, + CompleteResetPasswordProps, +} from './interfaces'; + +const mockSubmitNewPassword = (newPassword: string) => Promise.resolve(); + +export const Subject = ({ + submitNewPassword = mockSubmitNewPassword, + hasConfirmedRecoveryKey = false, + recoveryKeyExists = undefined, + testErrorMessage = '', +}: Partial & { + recoveryKeyExists?: boolean | undefined; + testErrorMessage?: string; +}) => { + const email = MOCK_EMAIL; + const [errorMessage, setErrorMessage] = useState(testErrorMessage); + const locationState = { + code: MOCK_HEXSTRING_32, + email, + token: MOCK_HEXSTRING_32, + uid: MOCK_UID, + recoveryKeyExists, + } as CompleteResetPasswordLocationState; + + return ( + + + + ); +}; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/container.tsx new file mode 100644 index 0000000000..f3795d6c23 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/container.tsx @@ -0,0 +1,146 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useState } from 'react'; +import { RouteComponentProps, useLocation, useNavigate } from '@reach/router'; +import { useAuthClient, useFtlMsgResolver } from '../../../models'; +import { getLocalizedErrorMessage } from '../../../lib/auth-errors/auth-errors'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; +import ConfirmResetPassword from '.'; +import { + ConfirmResetPasswordLocationState, + RecoveryKeyCheckResult, +} from './interfaces'; +import { ResendStatus } from '../../../lib/types'; + +const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { + const [resendStatus, setResendStatus] = useState( + ResendStatus['not sent'] + ); + const [errorMessage, setErrorMessage] = useState(''); + const authClient = useAuthClient(); + const ftlMsgResolver = useFtlMsgResolver(); + + const navigate = useNavigate(); + let location = useLocation(); + + const { email, metricsContext } = + (location.state as ConfirmResetPasswordLocationState) || {}; + + const searchParams = location.search; + + const handleNavigation = ( + code: string, + emailToHashWith: string, + token: string, + uid: string, + estimatedSyncDeviceCount?: number, + recoveryKeyExists?: boolean + ) => { + if (recoveryKeyExists === true) { + navigate(`/account_recovery_confirm_key${searchParams}`, { + state: { + code, + email, + emailToHashWith, + estimatedSyncDeviceCount, + recoveryKeyExists, + token, + uid, + }, + }); + } else { + navigate(`/complete_reset_password${searchParams}`, { + state: { + code, + email, + emailToHashWith, + estimatedSyncDeviceCount, + recoveryKeyExists, + token, + uid, + }, + }); + } + }; + + const checkForRecoveryKey = async () => { + try { + const result: RecoveryKeyCheckResult = await authClient.recoveryKeyExists( + undefined, + email + ); + return result; + } catch (error) { + return { + exist: undefined, + estimatedSyncDeviceCount: undefined, + } as RecoveryKeyCheckResult; + } + }; + + const verifyCode = async (otpCode: string) => { + setErrorMessage(''); + setResendStatus(ResendStatus['not sent']); + const options = { metricsContext }; + try { + const { code, emailToHashWith, token, uid } = + await authClient.passwordForgotVerifyOtp(email, otpCode, options); + const { exists: recoveryKeyExists, estimatedSyncDeviceCount } = + await checkForRecoveryKey(); + handleNavigation( + code, + emailToHashWith, + token, + uid, + estimatedSyncDeviceCount, + recoveryKeyExists + ); + } catch (error) { + const localizerErrorMessage = getLocalizedErrorMessage( + ftlMsgResolver, + error + ); + setErrorMessage(localizerErrorMessage); + } + }; + + const resendCode = async () => { + setErrorMessage(''); + const options = { metricsContext }; + try { + await authClient.passwordForgotSendOtp(email, options); + return true; + } catch (err) { + const localizedErrorMessage = getLocalizedErrorMessage( + ftlMsgResolver, + err + ); + setErrorMessage(localizedErrorMessage); + return false; + } + }; + + if (!email) { + navigate(`/reset_password${searchParams}`); + return ; + } + + return ( + + ); +}; + +export default ConfirmResetPasswordContainer; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/en.ftl b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/en.ftl new file mode 100644 index 0000000000..3963e85123 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/en.ftl @@ -0,0 +1,26 @@ +## Confirm Reset Password With Code + +confirm-reset-password-otp-flow-name = Reset password + +# The confirmation code is an 8-digit confirmation code sent by email +# Used to confirm possession of the email account +confirm-reset-password-otp-heading = Enter confirmation code + +# Text within span appears in bold +# $email - email address for which a password reset was requested, and where confirmation code was sent +# code contains numbers only +confirm-reset-password-otp-instruction = Enter the 8-digit confirmation code we sent to { $email } within 10 minutes. + +# Shown above a group of 8 single-digit input boxes +# Only numbers allowed +confirm-reset-password-otp-input-group-label = Enter 8-digit code + +# Clicking the button submits and verifies the code +# If succesful, continues to the next step of the password reset +confirm-reset-password-otp-submit-button = Continue + +# Button to request a new reset password confirmation code +confirm-reset-password-otp-resend-code-button = Resend code + +# LInk to cancel the password reset and sign in with a different account +confirm-reset-password-otp-different-account-link = Use a different account diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.stories.tsx new file mode 100644 index 0000000000..336672ab9c --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.stories.tsx @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { Meta } from '@storybook/react'; +import { withLocalization } from 'fxa-react/lib/storybooks'; +import ConfirmResetPassword from '.'; +import { Subject } from './mocks'; + +export default { + title: 'Pages/ResetPasswordRedesign/ConfirmResetPassword', + component: ConfirmResetPassword, + decorators: [withLocalization], +} as Meta; + +export const Default = () => ; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.test.tsx new file mode 100644 index 0000000000..fb26c9edc8 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.test.tsx @@ -0,0 +1,87 @@ +// TODO in FXA-7890 import tests from previous design and update + +/* 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 { Subject } from './mocks'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// add Glean mocks + +const mockResendCode = jest.fn(() => Promise.resolve(true)); +const mockVerifyCode = jest.fn((code: string) => Promise.resolve()); + +describe('ConfirmResetPassword', () => { + beforeEach(() => { + mockResendCode.mockClear(); + mockVerifyCode.mockClear(); + }); + + it('renders as expected', async () => { + renderWithLocalizationProvider(); + + await expect( + screen.getByRole('heading', { name: 'Enter confirmation code' }) + ).toBeVisible(); + + expect( + screen.getByText(/Enter the 8-digit confirmation code we sent to/) + ).toBeVisible(); + + expect(screen.getAllByRole('textbox')).toHaveLength(8); + const buttons = await screen.findAllByRole('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toHaveTextContent('Continue'); + expect(buttons[0]).toBeDisabled(); + expect(buttons[1]).toHaveTextContent('Resend code'); + + const links = await screen.findAllByRole('link'); + expect(links).toHaveLength(3); + expect(links[0]).toHaveAccessibleName(/Mozilla logo/); + expect(links[1]).toHaveTextContent('Sign in'); + expect(links[2]).toHaveTextContent('Use a different account'); + }); + + it('submits with valid code', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider(); + + const textboxes = screen.getAllByRole('textbox'); + await user.click(textboxes[0]); + await waitFor(() => { + user.paste('12345678'); + }); + + // name here is the accessible name from aria-label + const submitButton = screen.getByRole('button', { + name: 'Submit 12345678', + }); + expect(submitButton).toBeEnabled(); + + await waitFor(() => user.click(submitButton)); + expect(mockVerifyCode).toHaveBeenCalledTimes(1); + expect(mockVerifyCode).toHaveBeenCalledWith('12345678'); + }); + + it('handles resend code', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider(); + + const resendButton = screen.getByRole('button', { + name: 'Resend code', + }); + + await waitFor(() => user.click(resendButton)); + expect(mockResendCode).toHaveBeenCalledTimes(1); + + expect( + screen.getByText( + 'Email re-sent. Add accounts@firefox.com to your contacts to ensure a smooth delivery.' + ) + ).toBeVisible(); + }); +}); diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.tsx new file mode 100644 index 0000000000..e014356c7a --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/index.tsx @@ -0,0 +1,94 @@ +/* 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 AppLayout from '../../../components/AppLayout'; +import FormVerifyTotp from '../../../components/FormVerifyTotp'; +import { ConfirmResetPasswordProps } from './interfaces'; +import { RouteComponentProps } from '@reach/router'; +import { useFtlMsgResolver } from '../../../models'; +import LinkRememberPassword from '../../../components/LinkRememberPassword'; +import { FtlMsg } from 'fxa-react/lib/utils'; +import { ResendEmailSuccessBanner } from '../../../components/Banner'; +import { ResendStatus } from '../../../lib/types'; +import { EmailCodeImage } from '../../../components/images'; + +const ConfirmResetPassword = ({ + email, + errorMessage, + resendCode, + resendStatus, + searchParams, + setErrorMessage, + setResendStatus, + verifyCode, +}: ConfirmResetPasswordProps & RouteComponentProps) => { + const ftlMsgResolver = useFtlMsgResolver(); + + const localizedInputGroupLabel = ftlMsgResolver.getMsg( + 'confirm-reset-password-otp-input-group-label', + 'Enter 8-digit code' + ); + const localizedSubmitButtonText = ftlMsgResolver.getMsg( + 'confirm-reset-password-otp-submit-button', + 'Continue' + ); + + const spanElement = {email}; + + const handleResend = async () => { + const result = await resendCode(); + if (result === true) { + setResendStatus(ResendStatus.sent); + } + }; + + return ( + + +

Reset password

+
+ + +

Enter confirmation code

+
+ +

+ Enter the 8-digit confirmation code we sent to {spanElement} within 10 + minutes. +

+
+ {resendStatus === ResendStatus['sent'] && } + + +
+ + + + + + Use a different account + + +
+
+ ); +}; + +export default ConfirmResetPassword; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/interfaces.ts new file mode 100644 index 0000000000..d2f50eb14b --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/interfaces.ts @@ -0,0 +1,27 @@ +/* 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 { MetricsContext } from 'fxa-auth-client/browser'; +import { ResendStatus } from '../../../lib/types'; + +export interface ConfirmResetPasswordLocationState { + email: string; + metricsContext: MetricsContext; +} + +export type RecoveryKeyCheckResult = { + exists?: boolean; + estimatedSyncDeviceCount?: number; +}; + +export type ConfirmResetPasswordProps = { + email: string; + errorMessage: string; + resendCode: () => Promise; + resendStatus: ResendStatus; + searchParams: string; + setErrorMessage: React.Dispatch>; + setResendStatus: React.Dispatch>; + verifyCode: (code: string) => Promise; +}; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/mocks.tsx new file mode 100644 index 0000000000..19264f4eaf --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ConfirmResetPassword/mocks.tsx @@ -0,0 +1,40 @@ +/* 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 ConfirmResetPassword from '.'; +import { MOCK_EMAIL } from '../../mocks'; +import { LocationProvider } from '@reach/router'; +import { ResendStatus } from '../../../lib/types'; +import { ConfirmResetPasswordProps } from './interfaces'; + +const mockVerifyCode = (code: string) => Promise.resolve(); +const mockResendCode = () => Promise.resolve(true); + +export const Subject = ({ + resendCode = mockResendCode, + verifyCode = mockVerifyCode, +}: Partial) => { + const email = MOCK_EMAIL; + const [errorMessage, setErrorMessage] = useState(''); + const [resendStatus, setResendStatus] = useState(ResendStatus['not sent']); + const searchParams = ''; + + return ( + + + + ); +}; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/container.tsx new file mode 100644 index 0000000000..c28f718cbf --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/container.tsx @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { RouteComponentProps, useLocation, useNavigate } from '@reach/router'; +import React, { useState } from 'react'; +import { getLocalizedErrorMessage } from '../../../lib/auth-errors/auth-errors'; +import { useAuthClient, useFtlMsgResolver } from '../../../models'; + +import { ResetPasswordContainerProps } from './interfaces'; +import { queryParamsToMetricsContext } from '../../../lib/metrics'; +import ResetPassword from '.'; + +const ResetPasswordContainer = ({ + flowQueryParams = {}, + serviceName, +}: ResetPasswordContainerProps & RouteComponentProps) => { + const authClient = useAuthClient(); + const ftlMsgResolver = useFtlMsgResolver(); + const location = useLocation(); + const navigate = useNavigate(); + + const searchParams = location.search; + + const [errorMessage, setErrorMessage] = useState(''); + + let localizedErrorMessage = ''; + + const requestResetPasswordCode = async (email: string) => { + const metricsContext = queryParamsToMetricsContext( + flowQueryParams as unknown as Record + ); + const options = { + metricsContext, + }; + try { + await authClient.passwordForgotSendOtp(email, options); + navigate(`/confirm_reset_password${searchParams}`, { + state: { email, metricsContext }, + }); + } catch (err) { + localizedErrorMessage = getLocalizedErrorMessage(ftlMsgResolver, err); + setErrorMessage(localizedErrorMessage); + } + }; + + return ( + + ); +}; + +export default ResetPasswordContainer; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/en.ftl b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/en.ftl new file mode 100644 index 0000000000..c26c5f8c54 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/en.ftl @@ -0,0 +1,16 @@ +## ResetPassword start 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" +password-reset-heading-w-default-service = Password reset 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 +password-reset-heading-w-custom-service = Password reset to continue to { $serviceName } + +password-reset-body = Enter your email and we’ll send you a confirmation code to confirm it’s really you. + +password-reset-email-input = + .label = Enter your email + +password-reset-submit-button = Send me reset instructions diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.stories.tsx new file mode 100644 index 0000000000..7547ba333e --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.stories.tsx @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import ResetPassword from '.'; +import { Meta } from '@storybook/react'; +import { withLocalization } from 'fxa-react/lib/storybooks'; +import { Subject } from './mocks'; + +export default { + title: 'Pages/ResetPasswordRedesign/ResetPassword', + component: ResetPassword, + decorators: [withLocalization], +} as Meta; + +export const Default = () => ; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.test.tsx new file mode 100644 index 0000000000..d13473a5d9 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.test.tsx @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import GleanMetrics from '../../../lib/glean'; +import { Subject } from './mocks'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { MozServices } from '../../../lib/types'; +import userEvent from '@testing-library/user-event'; +import { MOCK_EMAIL } from '../../mocks'; + +jest.mock('../../../lib/glean', () => ({ + __esModule: true, + default: { resetPassword: { view: jest.fn(), submit: jest.fn() } }, +})); + +const mockRequestResetPasswordCode = jest.fn((email: string) => + Promise.resolve() +); + +describe('ResetPassword', () => { + beforeEach(() => { + (GleanMetrics.resetPassword.view as jest.Mock).mockClear(); + (GleanMetrics.resetPassword.submit as jest.Mock).mockClear(); + mockRequestResetPasswordCode.mockClear(); + }); + + describe('renders', () => { + it('as expected with default service', async () => { + renderWithLocalizationProvider(); + + await expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'Password reset to continue to account settings' + ); + expect( + screen.getByRole('textbox', { name: 'Enter your email' }) + ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'Send me reset instructions' }) + ).toBeVisible(); + expect(screen.getByText('Remember your password?')).toBeVisible(); + expect(screen.getByRole('link', { name: 'Sign in' })).toBeVisible(); + }); + + it('as expected with custom service', async () => { + renderWithLocalizationProvider( + + ); + const headingEl = await screen.findByRole('heading', { level: 1 }); + expect(headingEl).toHaveTextContent( + `Password reset to continue to Firefox Sync` + ); + }); + + it('emits a Glean event on render', async () => { + renderWithLocalizationProvider(); + await expect(screen.getByRole('heading', { level: 1 })).toBeVisible(); + expect(GleanMetrics.resetPassword.view).toHaveBeenCalledTimes(1); + }); + }); + + describe('submit', () => { + it('trims trailing space in email', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + + await expect(screen.getByRole('heading', { level: 1 })).toBeVisible(); + + await waitFor(() => + user.type(screen.getByRole('textbox'), `${MOCK_EMAIL} `) + ); + + await waitFor(() => user.click(screen.getByRole('button'))); + + expect(mockRequestResetPasswordCode).toBeCalledWith(MOCK_EMAIL); + + expect(GleanMetrics.resetPassword.view).toHaveBeenCalledTimes(1); + expect(GleanMetrics.resetPassword.submit).toHaveBeenCalledTimes(1); + }); + + it('trims leading space in email', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + + await expect(screen.getByRole('heading', { level: 1 })).toBeVisible(); + + await waitFor(() => + user.type(screen.getByRole('textbox'), ` ${MOCK_EMAIL}`) + ); + + await waitFor(() => user.click(screen.getByRole('button'))); + + expect(mockRequestResetPasswordCode).toBeCalledWith(MOCK_EMAIL); + expect(GleanMetrics.resetPassword.view).toHaveBeenCalledTimes(1); + expect(GleanMetrics.resetPassword.submit).toHaveBeenCalledTimes(1); + }); + + describe('handles errors', () => { + it('with an empty email', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + + await expect(screen.getByRole('heading', { level: 1 })).toBeVisible(); + await waitFor(() => user.click(screen.getByRole('button'))); + + expect(screen.getByText('Valid email required')).toBeVisible(); + expect(mockRequestResetPasswordCode).not.toBeCalled(); + expect(GleanMetrics.resetPassword.view).toHaveBeenCalledTimes(1); + expect(GleanMetrics.resetPassword.submit).not.toHaveBeenCalled(); + }); + + it('with an invalid email', async () => { + const user = userEvent.setup(); + renderWithLocalizationProvider( + + ); + + await expect(screen.getByRole('heading', { level: 1 })).toBeVisible(); + await waitFor(() => user.type(screen.getByRole('textbox'), 'boop')); + + await waitFor(() => user.click(screen.getByRole('button'))); + + expect(screen.getByText('Valid email required')).toBeVisible(); + expect(mockRequestResetPasswordCode).not.toBeCalled(); + expect(GleanMetrics.resetPassword.view).toHaveBeenCalledTimes(1); + expect(GleanMetrics.resetPassword.submit).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.tsx new file mode 100644 index 0000000000..c3269155f5 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/index.tsx @@ -0,0 +1,140 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { RouteComponentProps } from '@reach/router'; +import React, { useEffect, useState } from 'react'; +import { Control, useForm, useWatch } from 'react-hook-form'; +import { 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 { isEmailValid } from 'fxa-shared/email/helpers'; +import { ResetPasswordFormData, ResetPasswordProps } from './interfaces'; +import GleanMetrics from '../../../lib/glean'; + +export const viewName = 'reset-password'; + +// eslint-disable-next-line no-empty-pattern +const ResetPassword = ({ + errorMessage, + requestResetPasswordCode, + serviceName, + setErrorMessage, +}: ResetPasswordProps & RouteComponentProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + const ftlMsgResolver = useFtlMsgResolver(); + + useEffect(() => { + GleanMetrics.resetPassword.view(); + }, []); + + const { control, getValues, handleSubmit, register } = + useForm({ + mode: 'onTouched', + criteriaMode: 'all', + defaultValues: { + email: '', + }, + }); + + const onSubmit = async () => { + setIsSubmitting(true); + // clear error messages + setErrorMessage(''); + const email = getValues('email').trim(); + + if (!email || !isEmailValid(email)) { + setErrorMessage( + ftlMsgResolver.getMsg('auth-error-1011', 'Valid email required') + ); + } else { + GleanMetrics.resetPassword.submit(); + await requestResetPasswordCode(email); + } + setIsSubmitting(false); + }; + + // using a controlled component updates the link target as the input field is updated + // 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 your password? Sign in" + // immediately, the /signin page should have the original email. + // See https://github.com/mozilla/fxa-content-server/issues/5293. + const ControlledLinkRememberPassword = ({ + control, + }: { + control: Control; + }) => { + const email: string = useWatch({ + control, + name: 'email', + defaultValue: getValues().email, + }); + return ; + }; + + return ( + + + + {errorMessage && ( + +

{errorMessage}

+
+ )} + + +

+ Enter your email and we’ll send you a confirmation code to confirm + it’s really you. +

+
+ +
+ + setErrorMessage('')} + autoFocus + autoComplete="username" + spellCheck={false} + inputRef={register()} + /> + + + + + +
+ + +
+ ); +}; + +export default ResetPassword; diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/interfaces.ts b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/interfaces.ts new file mode 100644 index 0000000000..a4c28f323c --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/interfaces.ts @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { QueryParams } from '../../..'; +import { MozServices } from '../../../lib/types'; + +export interface ResetPasswordContainerProps { + flowQueryParams?: QueryParams; + serviceName: MozServices; +} + +export interface ResetPasswordProps { + errorMessage?: string; + requestResetPasswordCode: (email: string) => Promise; + serviceName: MozServices; + setErrorMessage: React.Dispatch>; +} + +export interface ResetPasswordFormData { + email: string; +} diff --git a/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/mocks.tsx new file mode 100644 index 0000000000..7575345593 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPasswordRedesign/ResetPassword/mocks.tsx @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MozServices } from '../../../lib/types'; +import React, { useState } from 'react'; +import ResetPassword from '.'; +import { LocationProvider } from '@reach/router'; +import { ResetPasswordProps } from './interfaces'; + +const defaultServiceName = MozServices.Default; +const mockRequestResetPasswordCode = (email: string) => Promise.resolve(); + +export const Subject = ({ + requestResetPasswordCode = mockRequestResetPasswordCode, + serviceName = defaultServiceName, +}: Partial) => { + const [errorMessage, setErrorMessage] = useState(''); + return ( + + + + ); +}; diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx index 160cb5a25e..ff45a901bc 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx @@ -174,9 +174,10 @@ export const SigninTotpCode = ({ />
- + {/* TODO in FXA-8636 replace with Link component once index reactified */} + Use a different account - + { expect(GleanMetrics.registration.submit).toHaveBeenCalledTimes(1); expect(GleanMetrics.registration.success).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith( - '/cannot_create_account?email=johndope%40example.com' + `/cannot_create_account?email=${encodeURIComponent(MOCK_EMAIL)}` ); expect(mockBeginSignupHandler).not.toBeCalled(); }); diff --git a/packages/fxa-settings/src/pages/mocks.tsx b/packages/fxa-settings/src/pages/mocks.tsx index 0d58cbfb2e..cc0888db5b 100644 --- a/packages/fxa-settings/src/pages/mocks.tsx +++ b/packages/fxa-settings/src/pages/mocks.tsx @@ -64,3 +64,4 @@ export function mockLoadingSpinnerModule() { return
loading spinner mock
; }); } +export const MOCK_RECOVERY_KEY = 'ARJDF300TFEPRJ7SFYB8QVNVYT60WWS2';