Merge pull request #16869 from mozilla/FXA-7890

feat(reset-password): Use OTP instead of link to start reset (front-end)
This commit is contained in:
Valerie Pomerleau 2024-05-22 10:19:07 -07:00 коммит произвёл GitHub
Родитель abc3eb1ca3 f9af7b14a1
Коммит 931d4258cb
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
68 изменённых файлов: 2321 добавлений и 156 удалений

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

@ -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 {

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

@ -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', {
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() {

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

@ -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}`

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

@ -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}`

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

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

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

@ -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();
});

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

@ -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,

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

@ -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();
});

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

@ -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 ({

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

@ -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);

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

@ -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);

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

@ -467,5 +467,8 @@
"subscriptionAccountReminders": {
"firstInterval": "5s",
"secondInterval": "10s"
},
"passwordForgotOtp": {
"enabled": true
}
}

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

@ -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,
};
},
},

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

@ -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,

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

@ -38,7 +38,7 @@ can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-body-subtext">
<%- include('/partials/automatedEmailChangePassword/index.mjml') %>
<%- include('/partials/automatedEmailNoAction/index.mjml') %>
</mj-text>
</mj-column>
</mj-section>

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

@ -16,7 +16,6 @@ const createStory = storyWithProps(
{
...MOCK_USER_INFO,
code: '96318398',
passwordChangeLink: 'http://localhost:3030/settings/change_password',
}
);

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

@ -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') %>

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

@ -898,7 +898,7 @@ const TESTS: [string, any, Record<string, any>?][] = [
['passwordForgotOtpEmail', new Map<string, Test | any>([
['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<string, any>?][] = [
{ 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' },
]],
])],

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

@ -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 {

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

@ -64,6 +64,6 @@
},
"featureFlags": {
"sendFxAStatusOnSettings": true,
"resetPasswordWithCode": false
"resetPasswordWithCode": true
}
}

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

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

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

@ -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,6 +278,42 @@ const AuthAndAccountSetupRoutes = ({
/>
{/* Reset password */}
{config.featureFlags?.resetPasswordWithCode === true ? (
<>
<ResetPasswordContainer
path="/reset_password/*"
{...{ flowQueryParams, serviceName }}
/>
<ConfirmResetPasswordContainer path="/confirm_reset_password/*" />
<CompleteResetPasswordWithCodeContainer
path="/complete_reset_password/*"
{...{ integration }}
/>
<CompleteResetPasswordWithCodeContainer
path="/account_recovery_reset_password/*"
{...{ integration }}
/>
<AccountRecoveryConfirmKeyContainer
path="/account_recovery_confirm_key/*"
{...{
serviceName,
}}
/>
</>
) : (
<>
<ResetPassword
path="/reset_password/*"
{...{ integration, flowQueryParams }}
/>
<ConfirmResetPassword
path="/confirm_reset_password/*"
{...{ integration }}
/>
<CompleteResetPasswordContainer
path="/complete_reset_password/*"
{...{ integration }}
/>
<LinkValidator
path="/account_recovery_confirm_key/*"
linkType={LinkType['reset-password']}
@ -296,18 +337,9 @@ const AuthAndAccountSetupRoutes = ({
path="/account_recovery_reset_password/*"
{...{ integration }}
/>
<CompleteResetPasswordContainer
path="/complete_reset_password/*"
{...{ integration }}
/>
<ConfirmResetPassword
path="/confirm_reset_password/*"
{...{ integration }}
/>
<ResetPassword
path="/reset_password/*"
{...{ integration, flowQueryParams }}
/>
</>
)}
<ResetPasswordWithRecoveryKeyVerified
path="/reset_password_with_recovery_key_verified/*"
{...{ integration, isSignedIn }}

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

@ -1,5 +1,6 @@
## LinkRememberPassword component
# Link that users can follow to sign in to their account
# This link exits the Reset Password flow
remember-pw-link = Remember your password? Sign in
# immediately before remember-password-signin-link
remember-password-text = Remember your password?
# link navigates to the sign in page
remember-password-signin-link = Sign in

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

@ -15,18 +15,8 @@ export default {
decorators: [withLocalization],
} as Meta;
const storyWithProps = ({ ...props }) => {
const story = () => (
export const Default = () => (
<LocationProvider>
<LinkRememberPassword
email={MOCK_ACCOUNT.primaryEmail.email}
{...props}
/>
<LinkRememberPassword email={MOCK_ACCOUNT.primaryEmail.email} />
</LocationProvider>
);
return story;
};
export const Default = storyWithProps({});
export const WithForceAuth = storyWithProps({ forceAuth: true });

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

@ -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 }) => (
<LocationProvider>
<LinkRememberPassword {...{ email, forceAuth }} />
<LinkRememberPassword {...{ email }} />
</LocationProvider>
);
@ -38,38 +35,24 @@ describe('LinkRememberPassword', () => {
bundle = await getFtlBundle('settings');
});
it('renders', () => {
it('renders as expected', () => {
renderWithLocalizationProvider(<Subject />);
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(<Subject />);
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(<Subject forceAuth={true} />);
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)}`
);
});
});

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

@ -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 (
<div className="text-sm mt-6">
<FtlMsg id="remember-pw-link">
{/* TODO: use Link component once signin is Reactified */}
<a href={linkHref} className="link-blue text-sm" id="remember-password">
Remember your password? Sign in
<div className="flex flex-wrap gap-2 justify-center text-sm mt-6">
<FtlMsg id="remember-password-text">
<p>Remember your password?</p>
</FtlMsg>
<FtlMsg id="remember-password-signin-link">
{/* TODO in FXA-8636 replace with Link component */}
<a href={linkHref} className="link-blue" id="remember-password">
Sign in
</a>
</FtlMsg>
</div>

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

@ -140,6 +140,7 @@ export const TotpInputGroup = ({
e: React.ClipboardEvent<HTMLInputElement>,
index: number
) => {
setErrorMessage('');
let currentIndex = index;
const currentCodeArray = [...codeArray];
const clipboardText = e.clipboardData.getData('text');
@ -237,7 +238,6 @@ export const TotpInputGroup = ({
<div
className={classNames(
// OTP code input must be displayed LTR for both LTR and RTL languages
// TODO in FXA-7890 verify that code is also displayed LTR in RTL emails
'flex my-2 rtl:flex-row-reverse',
codeLength === 6 && 'gap-2 mobileLandscape:gap-4',
codeLength === 8 && 'gap-1 mobileLandscape:gap-2'

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

@ -17,7 +17,7 @@ const WarningMessage = ({
warningType,
}: WarningMessageProps) => {
return (
<div className="mt-5 mb-8 text-sm" data-testid="warning-message-container">
<div className="mt-5 mb-8 text-xs" data-testid="warning-message-container">
<FtlMsg
id={warningMessageFtlId}
elems={{

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

@ -22,3 +22,5 @@ lock-image-aria-label =
.aria-label = An illustration of a lock
lightbulb-aria-label =
.aria-label = Illustration to represent creating a storage hint.
email-code-image-aria-label =
.aria-label = Illustration to represent an email containing a code.

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="227" height="183" fill="none"><g filter="url(#a)"><path fill="#fff" d="M166.47 69.583c-.224 0-.446.012-.67.017l.001-.017c0-19.652-16.043-35.583-35.833-35.583-16.071 0-29.67 10.506-34.215 24.978a24.7 24.7 0 0 0-11.481-2.814c-13.63 0-24.68 10.973-24.68 24.508 0 2.205.298 4.34.848 6.373a20 20 0 0 0-8.094-1.712c-11.03 0-19.973 8.88-19.973 19.834S41.315 125 52.346 125H166.47c15.411 0 27.903-12.405 27.903-27.708s-12.492-27.709-27.903-27.709"/></g><path fill="#007BED" d="m138.523 123.668-12.226 12.416-10.9-10.864-.884-1.086-1.473 1.552-31.965 34.298c1.326 1.397 2.946 2.017 4.714 2.017h80.869c1.767 0 3.535-.776 4.713-2.017l-31.375-34.764z"/><path fill="#179CF1" d="M114.299 126.17 81.265 94.703c-.728.886-1.31 1.773-1.6 2.955a6.4 6.4 0 0 0-.292 1.772v56.138c0 1.773.728 3.546 1.892 4.728l31.579-32.501zM172.873 96.818c-.289-1.038-1.009-2.077-1.73-2.966l-32.141 31.739 1.442 1.483 30.699 33.222c1.297-1.335 1.874-2.966 1.874-4.746V98.894c.144-.593 0-1.335-.144-2.076"/><path fill="#DABDFF" d="m114.289 126.639.884.886 10.9 10.475 12.226-11.803L171 94.77c-1.179-1.18-2.799-1.77-4.419-1.77H85.713c-1.767 0-3.535.738-4.713 2.066z"/><path fill="#FF4AA2" d="M205.373 79h-55c-8.284 0-15 6.716-15 15s6.716 15 15 15h55c8.284 0 15-6.716 15-15s-6.716-15-15-15"/><path fill="#fff" d="m158.758 95.804.704 3.051h-2.994l.704-3.051-2.34 2.126-1.459-2.602 2.994-.9-2.994-.95 1.535-2.552 2.264 2.126-.704-3.052h2.994l-.729 3.102 2.365-2.176 1.484 2.601-3.095.926 3.095.925-1.535 2.552zM185.176 95.804l.704 3.051h-2.994l.704-3.051-2.34 2.126-1.459-2.602 2.994-.9-2.994-.95 1.535-2.552 2.264 2.126-.704-3.052h2.994l-.73 3.102 2.366-2.176L189 93.527l-3.095.926 3.095.925-1.535 2.552zM171.967 95.804l.704 3.051h-2.994l.704-3.051-2.34 2.126-1.459-2.602 2.994-.9-2.994-.95 1.535-2.552 2.264 2.126-.704-3.052h2.994l-.729 3.102 2.365-2.176 1.484 2.601-3.095.926 3.095.925-1.535 2.552zM198.386 95.804l.704 3.051h-2.994l.704-3.051-2.34 2.126-1.459-2.602 2.994-.9-2.994-.95 1.535-2.552 2.264 2.126-.704-3.052h2.994l-.73 3.102 2.366-2.176 1.484 2.601-3.095.926 3.095.925-1.535 2.552z"/><g filter="url(#b)"><circle cx="71.373" cy="129" r="20" fill="#fff"/></g><path fill="#FF4AA2" stroke="#FF4AA2" d="m81.33 127.687-8.622-8.642a1.85 1.85 0 0 0-2.624 0 1.86 1.86 0 0 0 0 2.626l5.458 5.471H61.728A1.857 1.857 0 0 0 59.873 129c0 1.025.83 1.858 1.855 1.858h13.814l-5.458 5.471a1.86 1.86 0 0 0 0 2.626c.724.727 1.9.727 2.624 0l8.622-8.642a1.86 1.86 0 0 0 0-2.626Z"/><defs><filter id="a" width="226" height="155" x=".373" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="-2"/><feGaussianBlur stdDeviation="16"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0.564706 0 0 0 0 0.929412 0 0 0 0.12 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_3587_53404"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_3587_53404" result="shape"/></filter><filter id="b" width="104" height="104" x="19.373" y="79" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="2"/><feGaussianBlur stdDeviation="16"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0.564706 0 0 0 0 0.929412 0 0 0 0.12 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_3587_53404"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_3587_53404" result="shape"/></filter></defs></svg>

После

Ширина:  |  Высота:  |  Размер: 3.7 KiB

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

@ -48,3 +48,5 @@ export const Lock = () => <LockImage />;
export const Key = () => <RecoveryKeyImage />;
export const Lightbulb = () => <LightbulbImage />;
export const EmailCode = () => <EmailCode />;

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

@ -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) => (
<PreparedImage
ariaLabel="Illustration to represent an email containing a code."
ariaLabelFtlId="email-code-image-aria-label"
Image={EmailCode}
{...{ className, ariaHidden }}
/>
);

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

@ -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)

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

@ -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 dont have account recovery key saved,
some of your data will be erased (including synced server data like
history and bookmarks).
</WarningMessage>

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

@ -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();
});

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

@ -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(<Subject />, 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,
}

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

@ -132,8 +132,7 @@ describe('ConfirmResetPassword page', () => {
it('renders a "Remember your password?" link', () => {
renderWithHistory(<ConfirmResetPasswordWithWebIntegration />);
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();
});
});

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

@ -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 () => {

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

@ -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 (
<AccountRecoveryConfirmKey
{...{
accountResetToken,
code,
email,
emailToHashWith,
errorMessage,
estimatedSyncDeviceCount,
isSubmitting,
recoveryKeyExists,
serviceName,
setErrorMessage,
setIsSubmitting,
token,
verifyRecoveryKey,
uid,
}}
/>
);
};
export default AccountRecoveryConfirmKeyContainer;

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

@ -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
}
}
`;

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

@ -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 = () => <Subject />;

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

@ -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(<Subject />);
await screen.findByRole('heading', {
level: 1,
name: 'Reset password with account recovery key to continue to account settings',
});
screen.getByText(
'Please enter the one time use account recovery key you stored in a safe place to regain access to your Mozilla account.'
);
screen.getByTestId('warning-message-container');
screen.getByLabelText('Enter account recovery key');
screen.getByRole('button', { name: 'Confirm account recovery key' });
screen.getByRole('link', {
name: 'Dont have an account recovery key?',
});
});
describe('serviceName', () => {
it('renders the default', async () => {
renderWithLocalizationProvider(<Subject />);
await screen.findByText(`to continue to ${MozServices.Default}`);
});
it('renders non-default', async () => {
renderWithLocalizationProvider(
<Subject serviceName={MozServices.FirefoxSync} />
);
await screen.findByText(`to continue to Firefox Sync`);
});
});
describe('submit', () => {
describe('success', () => {
it('with valid recovery key', async () => {
const user = userEvent.setup();
renderWithLocalizationProvider(
<Subject verifyRecoveryKey={mockVerifyRecoveryKey} />
);
// 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(
<Subject verifyRecoveryKey={mockVerifyRecoveryKey} />
);
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(<Subject />);
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(
<Subject verifyRecoveryKey={mockVerifyRecoveryKey} />
);
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(
<Subject verifyRecoveryKey={mockVerifyRecoveryKey} />
);
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(
<Subject verifyRecoveryKey={mockVerifyRecoveryKey} />
);
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();
});
});
});
});

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

@ -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<AccountRecoveryConfirmKeyFormData>({
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 (
<AppLayout>
<CardHeader
headingWithDefaultServiceFtlId="account-recovery-confirm-key-heading-w-default-service"
headingWithCustomServiceFtlId="account-recovery-confirm-key-heading-w-custom-service"
headingText="Reset password with account recovery key"
{...{ serviceName }}
/>
{errorMessage && <Banner type={BannerType.error}>{errorMessage}</Banner>}
<FtlMsg id="account-recovery-confirm-key-instructions-2">
<p className="mt-4 text-sm">
Please enter the one time use account recovery key you stored in a
safe place to regain access to your Mozilla account.
</p>
</FtlMsg>
<WarningMessage
warningMessageFtlId="account-recovery-confirm-key-warning-message"
warningType="Note:"
>
If you reset your password and dont have account recovery key saved,
some of your data will be erased (including synced server data like
history and bookmarks).
</WarningMessage>
<form
noValidate
className="flex flex-col gap-4"
onSubmit={handleSubmit(onSubmit)}
data-testid="account-recovery-confirm-key-form"
>
<FtlMsg id="account-recovery-confirm-key-input" attrs={{ label: true }}>
<InputText
type="text"
label="Enter account recovery key"
name="recoveryKey"
autoFocus
// Crockford base32 encoding is case insensitive, so visually display as
// uppercase here but don't bother transforming the submit data to match
inputOnlyClassName="font-mono uppercase"
className="text-start"
autoComplete="off"
spellCheck={false}
prefixDataTestId="account-recovery-confirm-key"
inputRef={register({ required: true })}
/>
</FtlMsg>
<FtlMsg id="account-recovery-confirm-key-button">
<button
type="submit"
className="cta-primary cta-xl mb-6"
disabled={
isSubmitting ||
!formState.isDirty ||
!!formState.errors.recoveryKey
}
>
Confirm account recovery key
</button>
</FtlMsg>
</form>
<FtlMsg id="account-recovery-lost-recovery-key-link">
<Link
to={`/complete_reset_password${location.search}`}
className="link-blue text-sm"
id="lost-recovery-key"
state={{
accountResetToken,
code,
email,
emailToHashWith,
estimatedSyncDeviceCount,
recoveryKeyExists,
token,
uid,
}}
>
Dont have an account recovery key?
</Link>
</FtlMsg>
</AppLayout>
);
};
export default AccountRecoveryConfirmKey;

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

@ -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<React.SetStateAction<string>>;
setIsSubmitting: React.Dispatch<React.SetStateAction<boolean>>;
verifyRecoveryKey: (recoveryKey: string) => Promise<void>;
}

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

@ -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<AccountRecoveryConfirmKeyProps>) => {
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 (
<LocationProvider>
<AccountRecoveryConfirmKey
{...{
code,
email,
errorMessage,
estimatedSyncDeviceCount,
isSubmitting,
serviceName,
setErrorMessage,
setIsSubmitting,
token,
uid,
verifyRecoveryKey,
}}
/>
</LocationProvider>
);
};

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

@ -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 (
<CompleteResetPassword
{...{
email,
errorMessage,
searchParams,
submitNewPassword,
hasConfirmedRecoveryKey,
}}
locationState={location.state as CompleteResetPasswordLocationState}
/>
);
};
export default CompleteResetPasswordContainer;

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

@ -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 = () => <Subject recoveryKeyExists={false} />;
export const WithConfirmedRecoveryKey = () => (
<Subject recoveryKeyExists={true} hasConfirmedRecoveryKey />
);
export const UnknownRecoveryKeyStatus = () => <Subject />;

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

@ -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(<Subject />);
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). Thats because we encrypt your data with your password to protect your privacy. Youll still keep any subscriptions you may have and Pocket data will not be affected.'
)
).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(<Subject />);
expect(GleanMetrics.resetPassword.createNewView).toHaveBeenCalledTimes(1);
});
});
describe('reset with account recovery key confirmed', () => {
it('renders as expected', async () => {
renderWithLocalizationProvider(<Subject hasConfirmedRecoveryKey />);
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). Thats because we encrypt your data with your password to protect your privacy. Youll still keep any subscriptions you may have and Pocket data will not be affected.'
)
).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(<Subject hasConfirmedRecoveryKey />);
expect(
GleanMetrics.resetPassword.recoveryKeyCreatePasswordView
).toHaveBeenCalledTimes(1);
});
});
it('handles submit with valid password', async () => {
const user = userEvent.setup();
renderWithLocalizationProvider(
<Subject submitNewPassword={mockSubmitNewPassword} />
);
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(
<Subject testErrorMessage="Something went wrong" />
);
await waitFor(() =>
expect(
screen.getByRole('heading', {
name: 'Create new password',
})
).toBeVisible()
);
expect(screen.getByText('Something went wrong')).toBeVisible();
});
});

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

@ -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<CompleteResetPasswordFormData>({
mode: 'onTouched',
criteriaMode: 'all',
defaultValues: {
newPassword: '',
confirmPassword: '',
},
});
const onSubmit = async () => {
setIsSubmitting(true);
await submitNewPassword(getValues('newPassword'));
setIsSubmitting(false);
};
return (
<AppLayout>
<CardHeader
headingText="Create new password"
headingTextFtlId="complete-reset-pw-header"
/>
{!hasConfirmedRecoveryKey &&
locationState.recoveryKeyExists === undefined && (
<Banner type={BannerType.error}>
<>
<FtlMsg id="complete-reset-password-recovery-key-error-v2">
<p>
Sorry, there was a problem checking if you have an account
recovery key.
</p>
</FtlMsg>
{/* TODO add metrics to measure if users see and click on this link */}
<FtlMsg id="complete-reset-password-recovery-key-link">
<Link
to={`/account_recovery_confirm_key${searchParams}`}
state={locationState}
className="link-white underline-offset-4"
>
Reset your password with your account recovery key.
</Link>
</FtlMsg>
</>
</Banner>
)}
{errorMessage && <Banner type={BannerType.error}>{errorMessage}</Banner>}
{hasConfirmedRecoveryKey ? (
<FtlMsg id="account-restored-success-message">
<p className="text-sm mb-4">
You have successfully restored your account using your account
recovery key. Create a new password to secure your data, and store
it in a safe location.
</p>
</FtlMsg>
) : (
<WarningMessage
warningMessageFtlId="complete-reset-password-warning-message-2"
warningType="Remember:"
>
When you reset your password, you reset your account. You may lose
some of your personal information (including history, bookmarks, and
passwords). Thats because we encrypt your data with your password to
protect your privacy. Youll still keep any subscriptions you may have
and Pocket data will not be affected.
</WarningMessage>
)}
{/* Hidden email field is to allow Fx password manager
to correctly save the updated password. Without it,
the password manager tries to save the old password
as the username. */}
<input type="email" value={email} className="hidden" readOnly />
<section className="text-start mt-4">
<FormPasswordWithBalloons
{...{
email,
errors,
formState,
getValues,
register,
trigger,
}}
passwordFormType="reset"
onSubmit={handleSubmit(onSubmit)}
loading={isSubmitting}
/>
</section>
<LinkRememberPassword email={email} />
</AppLayout>
);
};
export default CompleteResetPassword;

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

@ -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<Integration, 'type' | 'getServiceName'>
| CompleteResetPasswordOAuthIntegration;
export type CompleteResetPasswordContainerProps = {
integration: CompleteResetPasswordIntegration;
};
export interface CompleteResetPasswordProps {
email: string;
errorMessage: string;
locationState: CompleteResetPasswordLocationState;
submitNewPassword: (newPassword: string) => Promise<void>;
hasConfirmedRecoveryKey?: boolean;
searchParams?: string;
}
export type AccountResetData = {
authAt: number;
keyFetchToken: string;
sessionToken: string;
uid: string;
unwrapBKey: string;
verified: boolean;
};

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

@ -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<CompleteResetPasswordProps> & {
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 (
<LocationProvider>
<CompleteResetPassword
{...{
email,
errorMessage,
locationState,
setErrorMessage,
submitNewPassword,
hasConfirmedRecoveryKey,
}}
/>
</LocationProvider>
);
};

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

@ -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>(
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 <LoadingSpinner fullScreen />;
}
return (
<ConfirmResetPassword
{...{
email,
errorMessage,
resendCode,
resendStatus,
searchParams,
setErrorMessage,
setResendStatus,
verifyCode,
}}
/>
);
};
export default ConfirmResetPasswordContainer;

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

@ -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 <span>{ $email }</span> 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

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

@ -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 = () => <Subject />;

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

@ -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(<Subject />);
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(<Subject verifyCode={mockVerifyCode} />);
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(<Subject resendCode={mockResendCode} />);
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();
});
});

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

@ -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 = <span className="font-bold">{email}</span>;
const handleResend = async () => {
const result = await resendCode();
if (result === true) {
setResendStatus(ResendStatus.sent);
}
};
return (
<AppLayout>
<FtlMsg id="confirm-reset-password-otp-flow-name">
<p className="text-start text-grey-400 text-sm">Reset password</p>
</FtlMsg>
<EmailCodeImage />
<FtlMsg id="confirm-reset-password-otp-heading">
<h2 className="card-header text-start my-4">Enter confirmation code</h2>
</FtlMsg>
<FtlMsg
id="confirm-reset-password-otp-instruction"
vars={{ email }}
elems={{ span: spanElement }}
>
<p className="text-start">
Enter the 8-digit confirmation code we sent to {spanElement} within 10
minutes.
</p>
</FtlMsg>
{resendStatus === ResendStatus['sent'] && <ResendEmailSuccessBanner />}
<FormVerifyTotp
codeLength={8}
{...{
errorMessage,
localizedInputGroupLabel,
localizedSubmitButtonText,
setErrorMessage,
verifyCode,
}}
/>
<LinkRememberPassword {...{ email }} />
<div className="flex justify-between mt-8 text-sm">
<FtlMsg id="confirm-reset-password-otp-resend-code-button">
<button type="button" className="link-blue" onClick={handleResend}>
Resend code
</button>
</FtlMsg>
<FtlMsg id="confirm-reset-password-otp-different-account-link">
<a href={`/${searchParams}`} className="link-blue">
Use a different account
</a>
</FtlMsg>
</div>
</AppLayout>
);
};
export default ConfirmResetPassword;

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

@ -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<boolean>;
resendStatus: ResendStatus;
searchParams: string;
setErrorMessage: React.Dispatch<React.SetStateAction<string>>;
setResendStatus: React.Dispatch<React.SetStateAction<ResendStatus>>;
verifyCode: (code: string) => Promise<void>;
};

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

@ -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<ConfirmResetPasswordProps>) => {
const email = MOCK_EMAIL;
const [errorMessage, setErrorMessage] = useState('');
const [resendStatus, setResendStatus] = useState(ResendStatus['not sent']);
const searchParams = '';
return (
<LocationProvider>
<ConfirmResetPassword
{...{
email,
errorMessage,
resendCode,
resendStatus,
searchParams,
setErrorMessage,
setResendStatus,
verifyCode,
}}
/>
</LocationProvider>
);
};

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

@ -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<string>('');
let localizedErrorMessage = '';
const requestResetPasswordCode = async (email: string) => {
const metricsContext = queryParamsToMetricsContext(
flowQueryParams as unknown as Record<string, string>
);
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 (
<ResetPassword
{...{
errorMessage,
requestResetPasswordCode,
serviceName,
setErrorMessage,
}}
/>
);
};
export default ResetPasswordContainer;

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

@ -0,0 +1,16 @@
## ResetPassword start page
# Strings within the <span> elements appear as a subheading.
# If more appropriate in a locale, the string within the <span>, "to continue to account settings" can stand alone as "Continue to account settings"
password-reset-heading-w-default-service = Password reset <span>to continue to account settings</span>
# Strings within the <span> elements appear as a subheading.
# If more appropriate in a locale, the string within the <span>, "to continue to { $serviceName }" can stand alone as "Continue to { $serviceName }"
# { $serviceName } represents a product name (e.g., Mozilla VPN) that will be passed in as a variable
password-reset-heading-w-custom-service = Password reset <span>to continue to { $serviceName }</span>
password-reset-body = Enter your email and well send you a confirmation code to confirm its really you.
password-reset-email-input =
.label = Enter your email
password-reset-submit-button = Send me reset instructions

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

@ -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 = () => <Subject />;

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

@ -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(<Subject />);
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(
<Subject serviceName={MozServices.FirefoxSync} />
);
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(<Subject />);
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(
<Subject requestResetPasswordCode={mockRequestResetPasswordCode} />
);
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(
<Subject requestResetPasswordCode={mockRequestResetPasswordCode} />
);
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(
<Subject requestResetPasswordCode={mockRequestResetPasswordCode} />
);
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(
<Subject requestResetPasswordCode={mockRequestResetPasswordCode} />
);
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();
});
});
});
});

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

@ -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<ResetPasswordFormData>({
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<ResetPasswordFormData>;
}) => {
const email: string = useWatch({
control,
name: 'email',
defaultValue: getValues().email,
});
return <LinkRememberPassword {...{ email }} />;
};
return (
<AppLayout>
<CardHeader
headingWithDefaultServiceFtlId="password-reset-heading-w-default-service"
headingWithCustomServiceFtlId="password-reset-heading-w-custom-service"
headingText="Password reset"
{...{ serviceName }}
/>
{errorMessage && (
<Banner type={BannerType.error}>
<p>{errorMessage}</p>
</Banner>
)}
<FtlMsg id="password-reset-body">
<p className="my-6">
Enter your email and well send you a confirmation code to confirm
its really you.
</p>
</FtlMsg>
<form
noValidate
className="flex flex-col gap-4"
onSubmit={handleSubmit(onSubmit)}
>
<FtlMsg id="password-reset-email-input" attrs={{ label: true }}>
<InputText
type="email"
label="Enter your email"
name="email"
onChange={() => setErrorMessage('')}
autoFocus
autoComplete="username"
spellCheck={false}
inputRef={register()}
/>
</FtlMsg>
<FtlMsg id="password-reset-submit-button">
<button
type="submit"
className="cta-primary cta-xl"
disabled={isSubmitting}
>
Send me reset instructions
</button>
</FtlMsg>
</form>
<ControlledLinkRememberPassword {...{ control }} />
</AppLayout>
);
};
export default ResetPassword;

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

@ -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<void>;
serviceName: MozServices;
setErrorMessage: React.Dispatch<React.SetStateAction<string>>;
}
export interface ResetPasswordFormData {
email: string;
}

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

@ -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<ResetPasswordProps>) => {
const [errorMessage, setErrorMessage] = useState('');
return (
<LocationProvider>
<ResetPassword
{...{
errorMessage,
serviceName,
requestResetPasswordCode,
setErrorMessage,
}}
/>
</LocationProvider>
);
};

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

@ -174,9 +174,10 @@ export const SigninTotpCode = ({
/>
<div className="mt-5 link-blue text-sm flex justify-between">
<FtlMsg id="signin-totp-code-other-account-link">
<Link to={`/signin${location.search}`} className="text-start">
{/* TODO in FXA-8636 replace with Link component once index reactified */}
<a href={`/${location.search}`} className="text-start">
Use a different account
</Link>
</a>
</FtlMsg>
<FtlMsg id="signin-totp-code-recovery-code-link">
<Link

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

@ -330,7 +330,7 @@ describe('Signup page', () => {
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();
});

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

@ -64,3 +64,4 @@ export function mockLoadingSpinnerModule() {
return <div>loading spinner mock</div>;
});
}
export const MOCK_RECOVERY_KEY = 'ARJDF300TFEPRJ7SFYB8QVNVYT60WWS2';