зеркало из https://github.com/mozilla/fxa.git
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:
Коммит
931d4258cb
|
@ -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 don’t 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: 'Don’t 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 don’t 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,
|
||||
}}
|
||||
>
|
||||
Don’t 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). That’s because we encrypt your data with your password to protect your privacy. You’ll still keep any subscriptions you may have and Pocket data will not be affected.'
|
||||
)
|
||||
).toBeVisible();
|
||||
// no recovery key specific messaging
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'You have successfully restored your account using your account recovery key. Create a new password to secure your data, and store it in a safe location.'
|
||||
)
|
||||
).not.toBeInTheDocument();
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(screen.getByLabelText('New password')).toBeVisible();
|
||||
expect(screen.getByLabelText('Re-enter password')).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Reset password' })
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Remember your password?')).toBeVisible();
|
||||
const link = screen.getByRole('link', { name: 'Sign in' });
|
||||
expect(link).toBeVisible();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
`/signin?email=${encodeURIComponent(MOCK_EMAIL)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the expected metrics on render', () => {
|
||||
renderWithLocalizationProvider(<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). That’s because we encrypt your data with your password to protect your privacy. You’ll still keep any subscriptions you may have and Pocket data will not be affected.'
|
||||
)
|
||||
).not.toBeInTheDocument();
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(screen.getByLabelText('New password')).toBeVisible();
|
||||
expect(screen.getByLabelText('Re-enter password')).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Reset password' })
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Remember your password?')).toBeVisible();
|
||||
const link = screen.getByRole('link', { name: 'Sign in' });
|
||||
expect(link).toBeVisible();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
`/signin?email=${encodeURIComponent(MOCK_EMAIL)}`
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the expected metrics on render', () => {
|
||||
renderWithLocalizationProvider(<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). That’s because we encrypt your data with your password to
|
||||
protect your privacy. You’ll still keep any subscriptions you may have
|
||||
and Pocket data will not be affected.
|
||||
</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 we’ll send you a confirmation code to confirm it’s really you.
|
||||
|
||||
password-reset-email-input =
|
||||
.label = Enter your email
|
||||
|
||||
password-reset-submit-button = Send me reset instructions
|
|
@ -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 we’ll send you a confirmation code to confirm
|
||||
it’s 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';
|
||||
|
|
Загрузка…
Ссылка в новой задаче