From 3b25b60b04a4efff86039faf4ab09664b2e3ddd4 Mon Sep 17 00:00:00 2001 From: Lauren Zugai Date: Wed, 19 Feb 2025 12:04:47 -0600 Subject: [PATCH] feat(react): Reactify email-first/Index page Because: * We are converting our Backbone pages to React This commit: * Adjusts content-server routing to make email-first servable by Express to React * Adds functionality from Backbone email-first to React email-first * Makes React email-first backwards compatible with Backbone until we feel confident in the full prod rollout, e.g. navigate vs hardNavigates * Adjusts stories/tests fixes FXA-8636, fixes FXA-8072 --- .../app/scripts/lib/router.js | 32 +-- .../server/bin/fxa-content-server.js | 7 +- .../server/config/local.json-dist | 2 +- .../server/lib/beta-settings.js | 1 + .../fxa-content-server/server/lib/routes.js | 5 +- .../server/lib/routes/get-index.js | 202 ++---------------- .../server/lib/routes/react-app/add-routes.js | 38 +++- .../server/lib/routes/react-app/index.js | 2 +- .../routes/react-app/react-route-client.js | 3 +- .../routes/react-app/react-route-server.js | 12 +- .../react-app/route-definition-index.js | 195 +++++++++++++++++ .../lib/routes/react-app/route-definitions.js | 2 + .../routes/react-app/route-groups-server.js | 4 +- .../tests/server/routes/get-index.js | 2 +- .../src/components/App/index.test.tsx | 11 + .../fxa-settings/src/components/App/index.tsx | 37 +++- .../components/LinkRememberPassword/index.tsx | 17 +- .../Settings/AlertBar/index.test.tsx | 1 + .../Settings/PageDeleteAccount/index.tsx | 22 +- .../src/lib/auth-errors/auth-errors.ts | 13 +- .../fxa-settings/src/lib/auth-errors/en.ftl | 5 + packages/fxa-settings/src/lib/cache.ts | 16 ++ .../fxa-settings/src/lib/channels/firefox.ts | 10 +- packages/fxa-settings/src/lib/config.ts | 1 + packages/fxa-settings/src/lib/constants.ts | 2 + .../email-domain-validator-resolve-domain.ts | 24 +++ .../src/lib/email-domain-validator.test.ts | 84 ++++++++ .../src/lib/email-domain-validator.ts | 85 ++++++++ packages/fxa-settings/src/lib/error-utils.ts | 7 + packages/fxa-settings/src/lib/hooks.tsx | 12 ++ .../src/lib/oauth/oauth-errors.ts | 9 +- .../src/models/integrations/utils.ts | 10 + packages/fxa-settings/src/models/mocks.tsx | 2 +- .../src/models/pages/index/index.ts | 5 + .../src/models/pages/index/query-params.ts | 13 ++ .../src/models/pages/signup/query-params.ts | 7 +- .../src/pages/Index/container.tsx | 159 ++++++++++++++ packages/fxa-settings/src/pages/Index/en.ftl | 6 + .../src/pages/Index/index.stories.tsx | 24 ++- .../src/pages/Index/index.test.tsx | 50 ++++- .../fxa-settings/src/pages/Index/index.tsx | 168 +++++++++++++-- .../src/pages/Index/interfaces.ts | 23 +- .../fxa-settings/src/pages/Index/mocks.tsx | 33 ++- .../ConfirmResetPassword/index.tsx | 39 ++-- .../ConfirmTotpResetPassword/index.tsx | 12 +- .../src/pages/Signin/SigninBounced/index.tsx | 14 +- .../pages/Signin/SigninPushCode/container.tsx | 8 +- .../Signin/SigninRecoveryCode/container.tsx | 16 +- .../Signin/SigninTokenCode/container.tsx | 8 +- .../pages/Signin/SigninTotpCode/container.tsx | 10 +- .../src/pages/Signin/SigninTotpCode/index.tsx | 39 ++-- .../pages/Signin/SigninUnblock/container.tsx | 10 +- .../src/pages/Signin/container.test.tsx | 8 +- .../src/pages/Signin/container.tsx | 111 +++++++--- .../fxa-settings/src/pages/Signin/index.tsx | 39 ++-- .../Signup/ConfirmSignupCode/container.tsx | 32 ++- .../pages/Signup/ConfirmSignupCode/index.tsx | 2 +- .../src/pages/Signup/container.test.tsx | 19 -- .../src/pages/Signup/container.tsx | 47 ++-- .../src/pages/Signup/index.stories.tsx | 10 +- .../src/pages/Signup/index.test.tsx | 75 +++---- .../fxa-settings/src/pages/Signup/index.tsx | 42 ++-- .../src/pages/Signup/interfaces.ts | 3 +- .../fxa-settings/src/pages/Signup/mocks.tsx | 12 +- 64 files changed, 1423 insertions(+), 496 deletions(-) create mode 100644 packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js create mode 100644 packages/fxa-settings/src/lib/email-domain-validator-resolve-domain.ts create mode 100644 packages/fxa-settings/src/lib/email-domain-validator.test.ts create mode 100644 packages/fxa-settings/src/lib/email-domain-validator.ts create mode 100644 packages/fxa-settings/src/models/pages/index/index.ts create mode 100644 packages/fxa-settings/src/models/pages/index/query-params.ts create mode 100644 packages/fxa-settings/src/pages/Index/container.tsx diff --git a/packages/fxa-content-server/app/scripts/lib/router.js b/packages/fxa-content-server/app/scripts/lib/router.js index 198ce0da67f..8db79a72f54 100644 --- a/packages/fxa-content-server/app/scripts/lib/router.js +++ b/packages/fxa-content-server/app/scripts/lib/router.js @@ -115,7 +115,9 @@ Cocktail.mixin(Router, ReactExperimentMixin); Router = Router.extend({ routes: { - '(/)': createViewHandler(IndexView), + '(/)': function () { + this.createReactOrBackboneViewHandler('/', IndexView); + }, 'account_recovery_confirm_key(/)': function () { this.createReactOrBackboneViewHandler( 'account_recovery_confirm_key', @@ -692,18 +694,22 @@ Router = Router.extend({ }, createReactViewHandler(routeName, additionalParams) { - const { deviceId, flowBeginTime, flowId } = - this.metrics.getFlowEventMetadata(); - - const link = `/${routeName}${Url.objToSearchString({ - showReactApp: true, - deviceId, - flowBeginTime, - flowId, - ...additionalParams, - })}`; - - this.navigateAway(link); + if (routeName === '/') { + this.navigateAway('/?showReactApp=true'); + } else { + const { deviceId, flowBeginTime, flowId } = + this.metrics.getFlowEventMetadata(); + + const link = `/${routeName}${Url.objToSearchString({ + showReactApp: true, + deviceId, + flowBeginTime, + flowId, + ...additionalParams, + })}`; + + this.navigateAway(link); + } }, createReactOrBackboneViewHandler( diff --git a/packages/fxa-content-server/server/bin/fxa-content-server.js b/packages/fxa-content-server/server/bin/fxa-content-server.js index 4bb841c5a88..e713b3cbb04 100755 --- a/packages/fxa-content-server/server/bin/fxa-content-server.js +++ b/packages/fxa-content-server/server/bin/fxa-content-server.js @@ -191,8 +191,6 @@ function makeApp() { const routeHelpers = routing(app, routeLogger); function addNonSettingsRoutes(middleware) { - addAllReactRoutesConditionally(app, routeHelpers, middleware, i18n); - /* This creates `app.whatever('/path' ...` handlers for every content-server route and * excludes routes in `react-app.js` if corresponding feature flags are on. We manually add * these excluded routes for content-server to serve in checks above if the feature flag is @@ -201,6 +199,11 @@ function makeApp() { * route implementations. */ routes.forEach(routeHelpers.addRoute); + // Adding React routes should come _after_ adding Backbone routes above because the Index + // page ('/'), which will get added to Backbone routing too in this function if conditions + // are not met for React, must come _after_ at least some of the frontend Backbone routing. + addAllReactRoutesConditionally(app, routeHelpers, middleware, i18n, config); + // must come after route handling but before wildcard routes app.use( serveStatic(STATIC_DIRECTORY, { diff --git a/packages/fxa-content-server/server/config/local.json-dist b/packages/fxa-content-server/server/config/local.json-dist index 796400144fd..42c0bc9d770 100644 --- a/packages/fxa-content-server/server/config/local.json-dist +++ b/packages/fxa-content-server/server/config/local.json-dist @@ -62,7 +62,7 @@ "resetPasswordRoutes": true, "signUpRoutes": true, "signInRoutes": true, - "emailFirstRoutes": false, + "emailFirstRoutes": true, "postVerifyThirdPartyAuthRoutes": true }, "featureFlags": { diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index 54a3d68e0dd..4c8fb986e7a 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -90,6 +90,7 @@ const settingsConfig = { showReactApp: { signUpRoutes: config.get('showReactApp.signUpRoutes'), signInRoutes: config.get('showReactApp.signInRoutes'), + emailFirstRoutes: config.get('showReactApp.emailFirstRoutes'), }, rolloutRates: { keyStretchV2: config.get('rolloutRates.keyStretchV2'), diff --git a/packages/fxa-content-server/server/lib/routes.js b/packages/fxa-content-server/server/lib/routes.js index c9837717fb9..ade5d9e5695 100644 --- a/packages/fxa-content-server/server/lib/routes.js +++ b/packages/fxa-content-server/server/lib/routes.js @@ -12,7 +12,8 @@ module.exports = function (config, i18n, statsd, glean) { const redirectVersionedToUnversioned = require('./routes/redirect-versioned-to-unversioned'); const reactRouteGroups = getServerReactRouteGroups( config.get('showReactApp'), - i18n + i18n, + config ); const routes = [ @@ -25,7 +26,7 @@ module.exports = function (config, i18n, statsd, glean) { require('./routes/get-oauth-success').default(reactRouteGroups), require('./routes/get-terms-privacy').default(reactRouteGroups, i18n), require('./routes/get-update-firefox')(config), - require('./routes/get-index')(config), + require('./routes/get-index').default(reactRouteGroups, config), require('./routes/get-ver.json'), require('./routes/get-client.json')(i18n), require('./routes/get-config')(i18n), diff --git a/packages/fxa-content-server/server/lib/routes/get-index.js b/packages/fxa-content-server/server/lib/routes/get-index.js index 3d1c25f78eb..a2321646d3a 100644 --- a/packages/fxa-content-server/server/lib/routes/get-index.js +++ b/packages/fxa-content-server/server/lib/routes/get-index.js @@ -4,190 +4,24 @@ 'use strict'; -const flowMetrics = require('../flow-metrics'); -const logger = require('../logging/log')('routes.index'); - -module.exports = function (config) { - let featureFlags; - const featureFlagConfig = config.get('featureFlags'); - if (featureFlagConfig.enabled) { - featureFlags = require('fxa-shared').featureFlags( - featureFlagConfig, - logger +const { + getIndexRouteDefinition, +} = require('./react-app/route-definition-index'); + +/** + * Remove index route ('/') from list if React feature flag is set to true + * and route is included in the emailFirstRoutes route group. + */ +/** @type {import("./react-app/types").GetBackboneRouteDefinition} */ +function getIndex(reactRouteGroups, config) { + const isOnReact = + reactRouteGroups.emailFirstRoutes.featureFlagOn && + reactRouteGroups.emailFirstRoutes.routes.find( + (route) => route.name === '/' ); - } else { - featureFlags = { get: () => ({}) }; - } - - const AUTH_SERVER_URL = config.get('fxaccount_url'); - const CLIENT_ID = config.get('oauth_client_id'); - const COPPA_ENABLED = config.get('coppa.enabled'); - const ENV = config.get('env'); - const FLOW_ID_KEY = config.get('flow_id_key'); - const MARKETING_EMAIL_ENABLED = config.get('marketing_email.enabled'); - const MARKETING_EMAIL_PREFERENCES_URL = config.get( - 'marketing_email.preferences_url' - ); - const MX_RECORD_VALIDATION = config.get('mxRecordValidation'); - const MAX_EVENT_OFFSET = config.get('client_metrics.max_event_offset'); - const REDIRECT_CHECK_ALLOW_LIST = config.get('redirect_check.allow_list'); - const SENTRY_CLIENT_DSN = config.get('sentry.dsn'); - const SENTRY_CLIENT_ENV = config.get('sentry.env'); - const SENTRY_SAMPLE_RATE = config.get('sentry.sampleRate'); - const SENTRY_TRACES_SAMPLE_RATE = config.get('sentry.tracesSampleRate'); - const SENTRY_CLIENT_NAME = config.get('sentry.clientName'); - const OAUTH_SERVER_URL = config.get('oauth_url'); - const PAIRING_CHANNEL_URI = config.get('pairing.server_base_uri'); - const PAIRING_CLIENTS = config.get('pairing.clients'); - const PROFILE_SERVER_URL = config.get('profile_url'); - const STATIC_RESOURCE_URL = config.get('static_resource_url'); - const SCOPED_KEYS_ENABLED = config.get('scopedKeys.enabled'); - const SCOPED_KEYS_VALIDATION = config.get('scopedKeys.validation'); - const SUBSCRIPTIONS = config.get('subscriptions'); - const GOOGLE_AUTH_CONFIG = config.get('googleAuthConfig'); - const APPLE_AUTH_CONFIG = config.get('appleAuthConfig'); - const PROMPT_NONE_ENABLED = config.get('oauth.prompt_none.enabled'); - const SHOW_REACT_APP = config.get('showReactApp'); - const BRAND_MESSAGING_MODE = config.get('brandMessagingMode'); - const FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS = config.get( - 'featureFlags.sendFxAStatusOnSettings' - ); - const FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN = config.get( - 'featureFlags.recoveryCodeSetupOnSyncSignIn' - ); - const FEATURE_FLAGS_ENABLE_ADDING_2FA_BACKUP_PHONE = config.get( - 'featureFlags.enableAdding2FABackupPhone' - ); - const FEATURE_FLAGS_ENABLE_USING_2FA_BACKUP_PHONE = config.get( - 'featureFlags.enableUsing2FABackupPhone' - ); - const GLEAN_ENABLED = config.get('glean.enabled'); - const GLEAN_APPLICATION_ID = config.get('glean.applicationId'); - const GLEAN_UPLOAD_ENABLED = config.get('glean.uploadEnabled'); - const GLEAN_APP_CHANNEL = config.get('glean.appChannel'); - const GLEAN_SERVER_ENDPOINT = config.get('glean.serverEndpoint'); - const GLEAN_LOG_PINGS = config.get('glean.logPings'); - const GLEAN_DEBUG_VIEW_TAG = config.get('glean.debugViewTag'); - - // Rather than relay all rollout rates, hand pick the ones that are applicable - const ROLLOUT_RATES = config.get('rolloutRates'); - - // Note that this list is only enforced for clients that use login_hint/email - // with prompt=none. id_token_hint clients are not subject to this check. - const PROMPT_NONE_ENABLED_CLIENT_IDS = new Set( - config.get('oauth.prompt_none.enabled_client_ids') - ); - - // add version from package.json to config - const RELEASE = require('../../../package.json').version; - const WEBPACK_PUBLIC_PATH = `${STATIC_RESOURCE_URL}/${config.get( - 'jsResourcePath' - )}/`; - - const configForFrontEnd = { - authServerUrl: AUTH_SERVER_URL, - maxEventOffset: MAX_EVENT_OFFSET, - env: ENV, - isCoppaEnabled: COPPA_ENABLED, - isPromptNoneEnabled: PROMPT_NONE_ENABLED, - googleAuthConfig: GOOGLE_AUTH_CONFIG, - appleAuthConfig: APPLE_AUTH_CONFIG, - marketingEmailEnabled: MARKETING_EMAIL_ENABLED, - marketingEmailPreferencesUrl: MARKETING_EMAIL_PREFERENCES_URL, - mxRecordValidation: MX_RECORD_VALIDATION, - oAuthClientId: CLIENT_ID, - oAuthUrl: OAUTH_SERVER_URL, - pairingChannelServerUri: PAIRING_CHANNEL_URI, - pairingClients: PAIRING_CLIENTS, - profileUrl: PROFILE_SERVER_URL, - release: RELEASE, - redirectAllowlist: REDIRECT_CHECK_ALLOW_LIST, - rolloutRates: ROLLOUT_RATES, - scopedKeysEnabled: SCOPED_KEYS_ENABLED, - scopedKeysValidation: SCOPED_KEYS_VALIDATION, - sentry: { - dsn: SENTRY_CLIENT_DSN, - env: SENTRY_CLIENT_ENV, - sampleRate: SENTRY_SAMPLE_RATE, - clientName: SENTRY_CLIENT_NAME, - tracesSampleRate: SENTRY_TRACES_SAMPLE_RATE, - }, - staticResourceUrl: STATIC_RESOURCE_URL, - subscriptions: SUBSCRIPTIONS, - webpackPublicPath: WEBPACK_PUBLIC_PATH, - showReactApp: SHOW_REACT_APP, - brandMessagingMode: BRAND_MESSAGING_MODE, - featureFlags: { - sendFxAStatusOnSettings: FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS, - recoveryCodeSetupOnSyncSignIn: - FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN, - enableAdding2FABackupPhone: FEATURE_FLAGS_ENABLE_ADDING_2FA_BACKUP_PHONE, - enableUsing2FABackupPhone: FEATURE_FLAGS_ENABLE_USING_2FA_BACKUP_PHONE, - }, - glean: { - // feature toggle - enabled: GLEAN_ENABLED, - // Glean SDK config - applicationId: GLEAN_APPLICATION_ID, - uploadEnabled: GLEAN_UPLOAD_ENABLED, - appDisplayVersion: RELEASE, - channel: GLEAN_APP_CHANNEL, - serverEndpoint: GLEAN_SERVER_ENDPOINT, - logPings: GLEAN_LOG_PINGS, - debugViewTag: GLEAN_DEBUG_VIEW_TAG, - }, - }; - - const NO_LONGER_SUPPORTED_CONTEXTS = new Set([ - 'fx_desktop_v1', - 'fx_desktop_v2', - 'fx_firstrun_v2', - 'iframe', - ]); - - return { - method: 'get', - path: '/', - process: async function (req, res) { - const flowEventData = flowMetrics.create(FLOW_ID_KEY); - - if (NO_LONGER_SUPPORTED_CONTEXTS.has(req.query.context)) { - return res.redirect(`/update_firefox?${req.originalUrl.split('?')[1]}`); - } - - let flags; - try { - flags = await featureFlags.get(); - } catch (err) { - logger.error('featureFlags.error', err); - flags = {}; - } - - const isPromptNoneEnabledForClient = - req.query.client_id && - PROMPT_NONE_ENABLED_CLIENT_IDS.has(req.query.client_id); - - res.render('index', { - // Note that bundlePath is added to templates as a build step - bundlePath: '/bundle', - config: encodeURIComponent( - JSON.stringify({ - ...configForFrontEnd, - isPromptNoneEnabled: PROMPT_NONE_ENABLED, - isPromptNoneEnabledForClient, - }) - ), - featureFlags: encodeURIComponent(JSON.stringify(flags)), - flowBeginTime: flowEventData.flowBeginTime, - flowId: flowEventData.flowId, - // Note that staticResourceUrl is added to templates as a build step - staticResourceUrl: STATIC_RESOURCE_URL, - }); + return isOnReact ? null : getIndexRouteDefinition(config); +} - if (req.headers.dnt === '1') { - logger.info('request.headers.dnt'); - } - }, - terminate: featureFlags.terminate, - }; +module.exports = { + default: getIndex, }; diff --git a/packages/fxa-content-server/server/lib/routes/react-app/add-routes.js b/packages/fxa-content-server/server/lib/routes/react-app/add-routes.js index 67d6154b97a..54bbaac5008 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/add-routes.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/add-routes.js @@ -3,16 +3,23 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const { getServerReactRouteGroups } = require('./route-groups-server'); -const config = require('../../configuration'); /** Add all routes from all route objects for fxa-settings or fxa-content-server to serve. * @type {import("./types").AddRoutes} */ -function addAllReactRoutesConditionally(app, routeHelpers, middleware, i18n) { - /** Check if the feature flag passed in is `true` and the request contains `?showReactApp=true`. - * If true, use the middleware passed ('createSettingsProxy' in dev, else 'modifySettingsStatic') - * for that route, allowing `fxa-settings` to serve the page. If false, skip the middleware and - * use the default routing middleware from `fxa-shared/express/routing.ts`. +function addAllReactRoutesConditionally( + app, + routeHelpers, + middleware, + i18n, + config +) { + /** Check if the feature flag passed in is `true`, and either the request contains + * `?showReactApp=true` or `fullProdRollout` is set to `true`. If true, use the + * middleware passed ('createSettingsProxy' in dev, else 'modifySettingsStatic') for + * that route, allowing `fxa-settings` to serve the page. If false, skip the middleware + * and use the default routing middleware from `fxa-shared/express/routing.ts` to + * serve the Backbone app. * @param {import("./types").ReactRouteGroup} */ function addReactRoutesConditionally({ @@ -25,11 +32,21 @@ function addAllReactRoutesConditionally(app, routeHelpers, middleware, i18n) { // possible TODO - `definition.method`s will either be 'get' or 'post'. Not sure if we need // this for any 'post' requests but shouldn't hurt anything; 'get' alone may suffice. app[definition.method](definition.path, (req, res, next) => { - if (req.query.showReactApp === 'true' || fullProdRollout === true) { + if (req.query.showReactApp === 'true' || fullProdRollout) { + // req.path === '/' seems to match some content-server routes like `/authorization`. + // To be sure we are only pointing to React for '/' with '?showReactApp=true' and not + // other routes when only '/' is set in react-app/index.js, explicitely check for + // `originalUrl` as well. + if (definition.path === '/') { + if (req.originalUrl.split('?')[0] === '/') { + return middleware(req, res, next); + } else { + return next('route'); + } + } return middleware(req, res, next); - } else { - next('route'); } + next('route'); }); // Manually add route for content-server to serve; occurs when above next('route'); is called routeHelpers.addRoute(definition); @@ -39,7 +56,8 @@ function addAllReactRoutesConditionally(app, routeHelpers, middleware, i18n) { const reactRouteGroups = getServerReactRouteGroups( config.get('showReactApp'), - i18n + i18n, + config ); for (const routeGroup in reactRouteGroups) { addReactRoutesConditionally(reactRouteGroups[routeGroup]); diff --git a/packages/fxa-content-server/server/lib/routes/react-app/index.js b/packages/fxa-content-server/server/lib/routes/react-app/index.js index d7d941124c3..f07af57fa8b 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/index.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/index.js @@ -25,7 +25,7 @@ const getReactRouteGroups = (showReactApp, reactRoute) => { return { emailFirstRoutes: { featureFlagOn: showReactApp.emailFirstRoutes, - routes: reactRoute.getRoutes([]), + routes: reactRoute.getRoutes(['/']), fullProdRollout: false, }, simpleRoutes: { diff --git a/packages/fxa-content-server/server/lib/routes/react-app/react-route-client.js b/packages/fxa-content-server/server/lib/routes/react-app/react-route-client.js index b23a42e1219..690221ce7fc 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/react-route-client.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/react-route-client.js @@ -17,7 +17,8 @@ const reactRouteClient = { getRoute(name) { if ( typeof name === 'string' && - (FRONTEND_ROUTES.includes(name) || + (name === '/' || + FRONTEND_ROUTES.includes(name) || PAIRING_ROUTES.includes(name) || OAUTH_SUCCESS_ROUTES.includes(name)) ) { diff --git a/packages/fxa-content-server/server/lib/routes/react-app/react-route-server.js b/packages/fxa-content-server/server/lib/routes/react-app/react-route-server.js index 367358af3fc..b36ce58409a 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/react-route-server.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/react-route-server.js @@ -14,6 +14,7 @@ const { getOAuthSuccessRouteDefinition, getTermsPrivacyRouteDefinition, } = require('./route-definitions'); +const { getIndexRouteDefinition } = require('./route-definition-index'); /** * Returns a route object with the `name` of the route and the route `definition`. @@ -21,13 +22,17 @@ const { class ReactRouteServer { /** @param {any} i18n * */ - constructor(i18n) { + constructor(i18n, config) { this.i18n = i18n; + this.config = config; } /** @param {String|RegExp} name */ getRoute(name) { if (typeof name === 'string') { + if (name === '/') { + return this.getIndex(name); + } if (FRONTEND_ROUTES.includes(name)) { return this.getFrontEnd(name); } @@ -72,6 +77,11 @@ class ReactRouteServer { return this.getRouteObject(name, getFrontEndRouteDefinition([name])); } + /** @private */ + getIndex(name) { + return this.getRouteObject(name, getIndexRouteDefinition(this.config)); + } + /** @private */ getFrontEndPairing(name) { return this.getRouteObject(name, getFrontEndPairingRouteDefinition([name])); diff --git a/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js new file mode 100644 index 00000000000..48f811d7b35 --- /dev/null +++ b/packages/fxa-content-server/server/lib/routes/react-app/route-definition-index.js @@ -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/. */ + +'use strict'; + +const flowMetrics = require('../../flow-metrics'); +const logger = require('../../logging/log')('routes.index'); + +function getIndexRouteDefinition(config) { + let featureFlags; + const featureFlagConfig = config.get('featureFlags'); + if (featureFlagConfig.enabled) { + featureFlags = require('fxa-shared').featureFlags( + featureFlagConfig, + logger + ); + } else { + featureFlags = { get: () => ({}) }; + } + + const AUTH_SERVER_URL = config.get('fxaccount_url'); + const CLIENT_ID = config.get('oauth_client_id'); + const COPPA_ENABLED = config.get('coppa.enabled'); + const ENV = config.get('env'); + const FLOW_ID_KEY = config.get('flow_id_key'); + const MARKETING_EMAIL_ENABLED = config.get('marketing_email.enabled'); + const MARKETING_EMAIL_PREFERENCES_URL = config.get( + 'marketing_email.preferences_url' + ); + const MX_RECORD_VALIDATION = config.get('mxRecordValidation'); + const MAX_EVENT_OFFSET = config.get('client_metrics.max_event_offset'); + const REDIRECT_CHECK_ALLOW_LIST = config.get('redirect_check.allow_list'); + const SENTRY_CLIENT_DSN = config.get('sentry.dsn'); + const SENTRY_CLIENT_ENV = config.get('sentry.env'); + const SENTRY_SAMPLE_RATE = config.get('sentry.sampleRate'); + const SENTRY_TRACES_SAMPLE_RATE = config.get('sentry.tracesSampleRate'); + const SENTRY_CLIENT_NAME = config.get('sentry.clientName'); + const OAUTH_SERVER_URL = config.get('oauth_url'); + const PAIRING_CHANNEL_URI = config.get('pairing.server_base_uri'); + const PAIRING_CLIENTS = config.get('pairing.clients'); + const PROFILE_SERVER_URL = config.get('profile_url'); + const STATIC_RESOURCE_URL = config.get('static_resource_url'); + const SCOPED_KEYS_ENABLED = config.get('scopedKeys.enabled'); + const SCOPED_KEYS_VALIDATION = config.get('scopedKeys.validation'); + const SUBSCRIPTIONS = config.get('subscriptions'); + const GOOGLE_AUTH_CONFIG = config.get('googleAuthConfig'); + const APPLE_AUTH_CONFIG = config.get('appleAuthConfig'); + const PROMPT_NONE_ENABLED = config.get('oauth.prompt_none.enabled'); + const SHOW_REACT_APP = config.get('showReactApp'); + const BRAND_MESSAGING_MODE = config.get('brandMessagingMode'); + const FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS = config.get( + 'featureFlags.sendFxAStatusOnSettings' + ); + const FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN = config.get( + 'featureFlags.recoveryCodeSetupOnSyncSignIn' + ); + const FEATURE_FLAGS_ENABLE_ADDING_2FA_BACKUP_PHONE = config.get( + 'featureFlags.enableAdding2FABackupPhone' + ); + const FEATURE_FLAGS_ENABLE_USING_2FA_BACKUP_PHONE = config.get( + 'featureFlags.enableUsing2FABackupPhone' + ); + const GLEAN_ENABLED = config.get('glean.enabled'); + const GLEAN_APPLICATION_ID = config.get('glean.applicationId'); + const GLEAN_UPLOAD_ENABLED = config.get('glean.uploadEnabled'); + const GLEAN_APP_CHANNEL = config.get('glean.appChannel'); + const GLEAN_SERVER_ENDPOINT = config.get('glean.serverEndpoint'); + const GLEAN_LOG_PINGS = config.get('glean.logPings'); + const GLEAN_DEBUG_VIEW_TAG = config.get('glean.debugViewTag'); + + // Rather than relay all rollout rates, hand pick the ones that are applicable + const ROLLOUT_RATES = config.get('rolloutRates'); + + // Note that this list is only enforced for clients that use login_hint/email + // with prompt=none. id_token_hint clients are not subject to this check. + const PROMPT_NONE_ENABLED_CLIENT_IDS = new Set( + config.get('oauth.prompt_none.enabled_client_ids') + ); + + // add version from package.json to config + const RELEASE = require('../../../../package.json').version; + const WEBPACK_PUBLIC_PATH = `${STATIC_RESOURCE_URL}/${config.get( + 'jsResourcePath' + )}/`; + + const configForFrontEnd = { + authServerUrl: AUTH_SERVER_URL, + maxEventOffset: MAX_EVENT_OFFSET, + env: ENV, + isCoppaEnabled: COPPA_ENABLED, + isPromptNoneEnabled: PROMPT_NONE_ENABLED, + googleAuthConfig: GOOGLE_AUTH_CONFIG, + appleAuthConfig: APPLE_AUTH_CONFIG, + marketingEmailEnabled: MARKETING_EMAIL_ENABLED, + marketingEmailPreferencesUrl: MARKETING_EMAIL_PREFERENCES_URL, + mxRecordValidation: MX_RECORD_VALIDATION, + oAuthClientId: CLIENT_ID, + oAuthUrl: OAUTH_SERVER_URL, + pairingChannelServerUri: PAIRING_CHANNEL_URI, + pairingClients: PAIRING_CLIENTS, + profileUrl: PROFILE_SERVER_URL, + release: RELEASE, + redirectAllowlist: REDIRECT_CHECK_ALLOW_LIST, + rolloutRates: ROLLOUT_RATES, + scopedKeysEnabled: SCOPED_KEYS_ENABLED, + scopedKeysValidation: SCOPED_KEYS_VALIDATION, + sentry: { + dsn: SENTRY_CLIENT_DSN, + env: SENTRY_CLIENT_ENV, + sampleRate: SENTRY_SAMPLE_RATE, + clientName: SENTRY_CLIENT_NAME, + tracesSampleRate: SENTRY_TRACES_SAMPLE_RATE, + }, + staticResourceUrl: STATIC_RESOURCE_URL, + subscriptions: SUBSCRIPTIONS, + webpackPublicPath: WEBPACK_PUBLIC_PATH, + showReactApp: SHOW_REACT_APP, + brandMessagingMode: BRAND_MESSAGING_MODE, + featureFlags: { + sendFxAStatusOnSettings: FEATURE_FLAGS_FXA_STATUS_ON_SETTINGS, + recoveryCodeSetupOnSyncSignIn: + FEATURE_FLAGS_RECOVERY_CODE_SETUP_ON_SYNC_SIGN_IN, + enableAdding2FABackupPhone: FEATURE_FLAGS_ENABLE_ADDING_2FA_BACKUP_PHONE, + enableUsing2FABackupPhone: FEATURE_FLAGS_ENABLE_USING_2FA_BACKUP_PHONE, + }, + glean: { + // feature toggle + enabled: GLEAN_ENABLED, + // Glean SDK config + applicationId: GLEAN_APPLICATION_ID, + uploadEnabled: GLEAN_UPLOAD_ENABLED, + appDisplayVersion: RELEASE, + channel: GLEAN_APP_CHANNEL, + serverEndpoint: GLEAN_SERVER_ENDPOINT, + logPings: GLEAN_LOG_PINGS, + debugViewTag: GLEAN_DEBUG_VIEW_TAG, + }, + }; + + const NO_LONGER_SUPPORTED_CONTEXTS = new Set([ + 'fx_desktop_v1', + 'fx_desktop_v2', + 'fx_firstrun_v2', + 'iframe', + ]); + + return { + method: 'get', + path: '/', + process: async function (req, res) { + const flowEventData = flowMetrics.create(FLOW_ID_KEY); + + if (NO_LONGER_SUPPORTED_CONTEXTS.has(req.query.context)) { + return res.redirect(`/update_firefox?${req.originalUrl.split('?')[1]}`); + } + + let flags; + try { + flags = await featureFlags.get(); + } catch (err) { + logger.error('featureFlags.error', err); + flags = {}; + } + + const isPromptNoneEnabledForClient = + req.query.client_id && + PROMPT_NONE_ENABLED_CLIENT_IDS.has(req.query.client_id); + + res.render('index', { + // Note that bundlePath is added to templates as a build step + bundlePath: '/bundle', + config: encodeURIComponent( + JSON.stringify({ + ...configForFrontEnd, + isPromptNoneEnabled: PROMPT_NONE_ENABLED, + isPromptNoneEnabledForClient, + }) + ), + featureFlags: encodeURIComponent(JSON.stringify(flags)), + flowBeginTime: flowEventData.flowBeginTime, + flowId: flowEventData.flowId, + // Note that staticResourceUrl is added to templates as a build step + staticResourceUrl: STATIC_RESOURCE_URL, + }); + + if (req.headers.dnt === '1') { + logger.info('request.headers.dnt'); + } + }, + terminate: featureFlags.terminate, + }; +} + +module.exports = { getIndexRouteDefinition }; diff --git a/packages/fxa-content-server/server/lib/routes/react-app/route-definitions.js b/packages/fxa-content-server/server/lib/routes/react-app/route-definitions.js index bf9efbc5e63..482db17055c 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/route-definitions.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/route-definitions.js @@ -2,6 +2,8 @@ * 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/. */ +// Note that the Index page is more complex and has its own file. + /** @type {import("./types").GetRouteDefinition} */ function getFrontEndRouteDefinition(routes) { const path = routes.join('|'); // prepare for use in a RegExp diff --git a/packages/fxa-content-server/server/lib/routes/react-app/route-groups-server.js b/packages/fxa-content-server/server/lib/routes/react-app/route-groups-server.js index 75f34751054..06a68f1ebfe 100644 --- a/packages/fxa-content-server/server/lib/routes/react-app/route-groups-server.js +++ b/packages/fxa-content-server/server/lib/routes/react-app/route-groups-server.js @@ -10,8 +10,8 @@ const { ReactRouteServer } = require('./react-route-server'); * @type {import("./types").GetReactRouteGroups} */ -const getServerReactRouteGroups = (showReactApp, i18n) => { - const reactRoute = new ReactRouteServer(i18n); +const getServerReactRouteGroups = (showReactApp, i18n, config) => { + const reactRoute = new ReactRouteServer(i18n, config); return getReactRouteGroups(showReactApp, reactRoute); }; diff --git a/packages/fxa-content-server/tests/server/routes/get-index.js b/packages/fxa-content-server/tests/server/routes/get-index.js index 1354f2b4efd..365712f01c2 100644 --- a/packages/fxa-content-server/tests/server/routes/get-index.js +++ b/packages/fxa-content-server/tests/server/routes/get-index.js @@ -4,7 +4,7 @@ const { registerSuite } = intern.getInterface('object'); const assert = intern.getPlugin('chai').assert; const sinon = require('sinon'); -const route = require('../../../server/lib/routes/get-index'); +const route = require('../../../server/lib/routes/react-app/route-definition-index'); const config = require('../../../server/lib/configuration'); var instance, request, response; diff --git a/packages/fxa-settings/src/components/App/index.test.tsx b/packages/fxa-settings/src/components/App/index.test.tsx index 7843d5b1297..bc1f0dc92b9 100644 --- a/packages/fxa-settings/src/components/App/index.test.tsx +++ b/packages/fxa-settings/src/components/App/index.test.tsx @@ -83,6 +83,7 @@ jest.mock('../../lib/glean', () => ({ getEnabled: jest.fn(), useGlean: jest.fn().mockReturnValue({ enabled: true }), accountPref: { view: jest.fn(), promoMonitorView: jest.fn() }, + emailFirst: { view: jest.fn() }, pageLoad: jest.fn(), }, })); @@ -163,7 +164,10 @@ describe('metrics', () => { }); (useIntegration as jest.Mock).mockReturnValue({ isSync: jest.fn(), + isDesktopRelay: jest.fn(), getServiceName: jest.fn(), + getClientId: jest.fn(), + data: {}, }); const flowInit = jest.spyOn(Metrics, 'init'); const userPreferencesInit = jest.spyOn(Metrics, 'initUserPreferences'); @@ -197,7 +201,10 @@ describe('glean', () => { }); const mockIntegration = { isSync: jest.fn(), + isDesktopRelay: jest.fn(), getServiceName: jest.fn(), + getClientId: jest.fn(), + data: {}, }; (useIntegration as jest.Mock).mockReturnValue(mockIntegration); (useLocalSignedInQueryState as jest.Mock).mockReturnValueOnce({ @@ -267,6 +274,7 @@ describe('loading spinner states', () => { }); (useIntegration as jest.Mock).mockReturnValue({ isSync: jest.fn().mockReturnValueOnce(true), + isDesktopRelay: jest.fn().mockReturnValueOnce(false), data: { context: {}, }, @@ -315,6 +323,7 @@ describe('SettingsRoutes', () => { }); (useIntegration as jest.Mock).mockReturnValue({ isSync: () => false, + isDesktopRelay: jest.fn().mockReturnValueOnce(false), getServiceName: jest.fn(), }); (useLocalSignedInQueryState as jest.Mock).mockReturnValue({ @@ -374,6 +383,7 @@ describe('SettingsRoutes', () => { it('redirects to sign out of sync warning', async () => { (useIntegration as jest.Mock).mockReturnValue({ isSync: () => true, + isDesktopRelay: () => false, data: { context: {}, }, @@ -417,6 +427,7 @@ describe('SettingsRoutes', () => { it('restores sync user when session is valid', async () => { (useIntegration as jest.Mock).mockReturnValue({ isSync: () => true, + isDesktopRelay: () => false, data: { context: {}, }, diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 9a1e6144580..86f7df6c0d5 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -87,6 +87,7 @@ import InlineRecoveryKeySetupContainer from '../../pages/InlineRecoveryKeySetup/ import SetPasswordContainer from '../../pages/PostVerify/SetPassword/container'; import SigninRecoveryChoiceContainer from '../../pages/Signin/SigninRecoveryChoice/container'; import SigninRecoveryPhoneContainer from '../../pages/Signin/SigninRecoveryPhone/container'; +import { IndexContainer } from '../../pages/Index/container'; const Settings = lazy(() => import('../Settings')); @@ -96,7 +97,9 @@ export const App = ({ const config = useConfig(); const session = useSession(); const integration = useIntegration(); - const isSync = integration != null && integration.isSync(); + const isWebChannelIntegration = + integration != null && + (integration.isSync() || integration.isDesktopRelay()); const { data: isSignedInData } = useLocalSignedInQueryState(); // GQL call for minimal metrics data @@ -119,28 +122,32 @@ export const App = ({ (async () => { let isValidSession = false; - if (isSync) { + if (isWebChannelIntegration) { // Request and update account data/state to match the browser state. - // When we are acessing FxA from the browser menu, the isSync flag will + // When we are acessing FxA from the browser menu or the user is going through + // the service=relay flow, the isWebChannelIntegration flag will // be set to true. If there is a user actively signed into the browser, // we should try to use that user's account when possible. - const syncUser = await firefox.requestSignedInUser( - integration.data.context + const userFromBrowser = await firefox.requestSignedInUser( + integration.data.context, + // TODO with React pairing flow, update this if pairing flow + false, + integration.data.service ); - if (syncUser && syncUser.sessionToken) { + if (userFromBrowser && userFromBrowser.sessionToken) { // If the session is valid, try to set it as the current account - isValidSession = await session.isValid(syncUser.sessionToken); + isValidSession = await session.isValid(userFromBrowser.sessionToken); if (isValidSession) { - const cachedUser = getAccountByUid(syncUser.uid); + const cachedUser = getAccountByUid(userFromBrowser.uid); if (cachedUser) { storeAccountData({ ...cachedUser, // Make sure we are apply the session token we validated - sessionToken: syncUser.sessionToken, + sessionToken: userFromBrowser.sessionToken, }); } else { - storeAccountData(syncUser); + storeAccountData(userFromBrowser); } } } @@ -153,7 +160,12 @@ export const App = ({ setIsSignedIn(isValidSession); })(); - }, [integration, isSync, isSignedInData?.isSignedIn, session]); + }, [ + integration, + isWebChannelIntegration, + isSignedInData?.isSignedIn, + session, + ]); // Because this query depends on the result of an initial query (in this case, // metrics), we need to run it separately. @@ -310,6 +322,9 @@ const AuthAndAccountSetupRoutes = ({ return ( + {/* Index */} + + {/* Legal */} diff --git a/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx b/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx index e8d96494799..30dfe785c40 100644 --- a/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx +++ b/packages/fxa-settings/src/components/LinkRememberPassword/index.tsx @@ -4,8 +4,9 @@ import React from 'react'; import { FtlMsg, hardNavigate } from 'fxa-react/lib/utils'; -import { useLocation } from '@reach/router'; +import { useLocation, useNavigate } from '@reach/router'; import { isEmailValid } from 'fxa-shared/email/helpers'; +import { useCheckReactEmailFirst } from '../../lib/hooks'; export type LinkRememberPasswordProps = { email?: string; @@ -20,13 +21,16 @@ const LinkRememberPassword = ({ }: LinkRememberPasswordProps) => { let linkHref: string; const location = useLocation(); + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); + const params = new URLSearchParams(location.search); params.delete('email'); params.delete('hasLinkedAccount'); params.delete('hasPassword'); params.delete('showReactApp'); - if (email && isEmailValid(email)) { + if (email && isEmailValid(email) && !shouldUseReactEmailFirst) { params.set('prefillEmail', email); linkHref = `/?${params.toString()}`; } else { @@ -42,6 +46,15 @@ const LinkRememberPassword = ({ // additional optional click handlong behavior clickHandler(); } + + if (shouldUseReactEmailFirst) { + navigate(linkHref, { + state: { + prefillEmail: email && isEmailValid(email) ? email : undefined, + }, + }); + return; + } hardNavigate(linkHref); }; diff --git a/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx b/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx index 403cc0d04e9..a575def5f15 100644 --- a/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx @@ -8,6 +8,7 @@ import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localiz import AlertBar from '.'; jest.mock('@apollo/client', () => ({ + ...jest.requireActual('@apollo/client'), useReactiveVar: (x: Function) => x(), })); diff --git a/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx b/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx index c262f3d536d..351b8cb7239 100644 --- a/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx +++ b/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx @@ -21,6 +21,7 @@ import LinkExternal from 'fxa-react/components/LinkExternal'; import { getLocalizedErrorMessage } from '../../../lib/error-utils'; import GleanMetrics from '../../../lib/glean'; import { useFtlMsgResolver } from '../../../models/hooks'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; type FormData = { password: string; @@ -107,6 +108,7 @@ export const PageDeleteAccount = (_: RouteComponentProps) => { const alertBar = useAlertBar(); const ftlMsgResolver = useFtlMsgResolver(); const goHome = useCallback(() => window.history.back(), []); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const account = useAccount(); @@ -143,7 +145,15 @@ export const PageDeleteAccount = (_: RouteComponentProps) => { 'flow.settings.account-delete', 'confirm-password.success' ); - hardNavigate('/', { delete_account_success: true }, true); + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + deleteAccountSuccess: true, + }, + }); + } else { + hardNavigate('/', { delete_account_success: true }, true); + } } catch (e) { const localizedError = getLocalizedErrorMessage(ftlMsgResolver, e); if (e.errno === AuthUiErrors.INCORRECT_PASSWORD.errno) { @@ -155,7 +165,15 @@ export const PageDeleteAccount = (_: RouteComponentProps) => { } } }, - [account, setErrorText, setValue, alertBar, ftlMsgResolver] + [ + account, + setErrorText, + setValue, + alertBar, + ftlMsgResolver, + navigate, + shouldUseReactEmailFirst, + ] ); const handleConfirmChange = diff --git a/packages/fxa-settings/src/lib/auth-errors/auth-errors.ts b/packages/fxa-settings/src/lib/auth-errors/auth-errors.ts index 29eb33833c2..66e1948f9f5 100644 --- a/packages/fxa-settings/src/lib/auth-errors/auth-errors.ts +++ b/packages/fxa-settings/src/lib/auth-errors/auth-errors.ts @@ -414,7 +414,7 @@ const ERRORS = { }, DIFFERENT_EMAIL_REQUIRED_FIREFOX_DOMAIN: { errno: 1020, - message: 'Enter a valid email address. firefox.com does not offer email.', + message: 'Mistyped email? firefox.com isn’t a valid email service', }, CHANNEL_TIMEOUT: { errno: 1021, @@ -629,11 +629,12 @@ const ERRORS = { errno: 1063, message: 'Could not get Subscription Platform Terms of Service' }, - INVALID_EMAIL_DOMAIN: { - errno: 1064, - message: 'Mistyped email? %(domain)s does not offer email.' - }, - */ + */ + INVALID_EMAIL_DOMAIN: { + errno: 1064, + message: 'Mistyped email? %(domain)s isn’t a valid email service', + interpolate: true, + }, IMAGE_TOO_LARGE: { errno: 1065, message: 'The image file size is too large to be uploaded.', diff --git a/packages/fxa-settings/src/lib/auth-errors/en.ftl b/packages/fxa-settings/src/lib/auth-errors/en.ftl index 574919dc4a6..7bf9f7415c6 100644 --- a/packages/fxa-settings/src/lib/auth-errors/en.ftl +++ b/packages/fxa-settings/src/lib/auth-errors/en.ftl @@ -37,8 +37,13 @@ auth-error-1003 = Local storage or cookies are still disabled auth-error-1008 = Your new password must be different auth-error-1010 = Valid password required auth-error-1011 = Valid email required +auth-error-1018 = Your confirmation email was just returned. Mistyped email? +auth-error-1020 = Mistyped email? firefox.com isn’t a valid email service auth-error-1031 = You must enter your age to sign up auth-error-1032 = You must enter a valid age to sign up auth-error-1054 = Invalid two-step authentication code auth-error-1056 = Invalid backup authentication code auth-error-1062 = Invalid redirect +# Shown when a user tries to sign up with an email address with a domain that doesn't receive emails +auth-error-1064 = Mistyped email? { $email } isn’t a valid email service +auth-error-1066 = Email masks can’t be used to create an account. diff --git a/packages/fxa-settings/src/lib/cache.ts b/packages/fxa-settings/src/lib/cache.ts index 9d5a13ddb62..7d771ff146e 100644 --- a/packages/fxa-settings/src/lib/cache.ts +++ b/packages/fxa-settings/src/lib/cache.ts @@ -4,6 +4,7 @@ import { Email } from '../models'; import { searchParam } from '../lib/utilities'; import config from './config'; import { StoredAccountData } from './storage-utils'; +import { Constants } from './constants'; const storage = Storage.factory('localStorage'); @@ -182,3 +183,18 @@ export const cache = new InMemoryCache({ }, }, }); + +/* + * Check that the React enrolled flag in local storage is set to `true`. + * Note that if users don't hit the Backbone JS bundle, this is not going + * to get set. + */ +export function isInReactExperiment() { + const storageReactExp = storage.get(Constants.STORAGE_REACT_EXPERIMENT); + try { + const parsedData = JSON.parse(storageReactExp); + return parsedData && parsedData.enrolled === true; + } catch (error) { + return false; + } +} diff --git a/packages/fxa-settings/src/lib/channels/firefox.ts b/packages/fxa-settings/src/lib/channels/firefox.ts index 672f3220fea..ee2bcc4a2cc 100644 --- a/packages/fxa-settings/src/lib/channels/firefox.ts +++ b/packages/fxa-settings/src/lib/channels/firefox.ts @@ -347,8 +347,13 @@ export class Firefox extends EventTarget { }); } + /* + * Sends an fxa_status and returns the signed in user if available. + */ async requestSignedInUser( - context: string + context: string, + isPairing: boolean, + service: string ): Promise { let timeout: number; return Promise.race([ @@ -370,7 +375,8 @@ export class Firefox extends EventTarget { requestAnimationFrame(() => { this.send(FirefoxCommand.FxAStatus, { context, - isPairing: false, + isPairing, + service, }); }); }), diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index 398f2d85558..7d08fa5cedf 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -82,6 +82,7 @@ export interface Config { showReactApp: { signUpRoutes: boolean; signInRoutes: boolean; + emailFirstRoutes: boolean; }; rolloutRates?: { keyStretchV2?: number; diff --git a/packages/fxa-settings/src/lib/constants.ts b/packages/fxa-settings/src/lib/constants.ts index c0bff3de1fe..6abe8510da0 100644 --- a/packages/fxa-settings/src/lib/constants.ts +++ b/packages/fxa-settings/src/lib/constants.ts @@ -195,4 +195,6 @@ export const Constants = { DISABLE_PROMO_ACCOUNT_RECOVERY_KEY_DO_IT_LATER: '__fxa_storage.disable_promo.account-recovery-do-it-later', + + STORAGE_REACT_EXPERIMENT: 'experiment.generalizedReactApp', }; diff --git a/packages/fxa-settings/src/lib/email-domain-validator-resolve-domain.ts b/packages/fxa-settings/src/lib/email-domain-validator-resolve-domain.ts new file mode 100644 index 00000000000..8b312e6d937 --- /dev/null +++ b/packages/fxa-settings/src/lib/email-domain-validator-resolve-domain.ts @@ -0,0 +1,24 @@ +/* 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/. */ + +// This function was moved to a separate file for easier mocking. + +const EMAIL_VALIDATION_ENDPOINT = '/validate-email-domain'; + +export async function resolveDomain(domain: string) { + console.log('in resolveDomain'); + const response = await fetch( + `${EMAIL_VALIDATION_ENDPOINT}?domain=${domain}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + if (!response.ok) { + throw new Error(`Failed to check domain ${domain}: ${response.statusText}`); + } + return response.json(); +} diff --git a/packages/fxa-settings/src/lib/email-domain-validator.test.ts b/packages/fxa-settings/src/lib/email-domain-validator.test.ts new file mode 100644 index 00000000000..8bbaf3bc29f --- /dev/null +++ b/packages/fxa-settings/src/lib/email-domain-validator.test.ts @@ -0,0 +1,84 @@ +/* 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 { checkEmailDomain } from './email-domain-validator'; +import { AuthUiErrors } from './auth-errors/auth-errors'; +import topEmailDomains from 'fxa-shared/email/topEmailDomains'; +import { resolveDomain } from './email-domain-validator-resolve-domain'; + +jest.mock('./email-domain-validator-resolve-domain', () => ({ + resolveDomain: jest.fn(), +})); + +describe('checkEmailDomain', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should throw EMAIL_REQUIRED if no domain is present', async () => { + await expect(checkEmailDomain('invalidEmail')).rejects.toBe( + AuthUiErrors.EMAIL_REQUIRED + ); + }); + + it('should skip validation for known top domains', async () => { + jest.spyOn(topEmailDomains, 'has').mockReturnValue(true); + await expect(checkEmailDomain('user@popular.com')).resolves.toBeUndefined(); + }); + + it('should allow submission on a successful MX record lookup', async () => { + (resolveDomain as jest.Mock).mockResolvedValueOnce({ result: 'MX' }); + await expect( + checkEmailDomain('user@valid-mx.com') + ).resolves.toBeUndefined(); + expect(resolveDomain).toHaveBeenCalledWith('valid-mx.com'); + }); + + it('should throw INVALID_EMAIL_DOMAIN on a successful A record lookup', async () => { + (resolveDomain as jest.Mock).mockResolvedValueOnce({ result: 'A' }); + await expect(checkEmailDomain('user@only-a.com')).rejects.toBe( + AuthUiErrors.INVALID_EMAIL_DOMAIN + ); + }); + + it('should throw INVALID_EMAIL_DOMAIN on a result of none', async () => { + (resolveDomain as jest.Mock).mockResolvedValueOnce({ result: 'none' }); + await expect(checkEmailDomain('user@no-records.com')).rejects.toBe( + AuthUiErrors.INVALID_EMAIL_DOMAIN + ); + }); + + it('should resolve when the domain is marked as skip', async () => { + (resolveDomain as jest.Mock).mockResolvedValueOnce({ result: 'skip' }); + await expect(checkEmailDomain('user@skip.com')).resolves.toBeUndefined(); + }); + + it('should allow submission on resolveDomain failure', async () => { + (resolveDomain as jest.Mock).mockRejectedValueOnce( + new Error('Network Error') + ); + await expect( + checkEmailDomain('user@server-down.com') + ).resolves.toBeUndefined(); + }); + + it('should reject immediately if the domain has previously failed', async () => { + (resolveDomain as jest.Mock).mockResolvedValueOnce({ result: 'none' }); + await expect(checkEmailDomain('user@failed.com')).rejects.toBe( + AuthUiErrors.INVALID_EMAIL_DOMAIN + ); + await expect(checkEmailDomain('user@failed.com')).rejects.toBe( + AuthUiErrors.INVALID_EMAIL_DOMAIN + ); + }); + + it('should resolve if the domain was previously resolved with an A record', async () => { + (resolveDomain as jest.Mock).mockResolvedValueOnce({ result: 'A' }); + await expect(checkEmailDomain('user@prev-a.com')).rejects.toBe( + AuthUiErrors.INVALID_EMAIL_DOMAIN + ); + await expect(checkEmailDomain('user@prev-a.com')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/fxa-settings/src/lib/email-domain-validator.ts b/packages/fxa-settings/src/lib/email-domain-validator.ts new file mode 100644 index 00000000000..0c916a87eec --- /dev/null +++ b/packages/fxa-settings/src/lib/email-domain-validator.ts @@ -0,0 +1,85 @@ +/* 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 { AuthUiErrors } from './auth-errors/auth-errors'; +import topEmailDomains from 'fxa-shared/email/topEmailDomains'; +import { resolveDomain } from './email-domain-validator-resolve-domain'; + +let previousDomain: string | undefined; +let previousDomainResult: string | undefined; + +/** + * This validates an email address's domain through DNS lookups. + * + * The validation is performed after the existing account email check during + * the form submission process. At this point, the email address has gone + * through: 1) regex validation, 2) an email mask check, and 3) existing + * email address check. + * + * Prior to sending the email domain to the server side, it is checked against + * a list of known top domains. If the domain is found in the list, then the + * process is skipped. + * + * There are three possible validation results: 'MX', 'A', and 'none'. + * - 'MX': MX record exists, proceed + * - 'A': no MX, but A record exists, display tooltip but allow submission + * - 'none: neither MX or A records found, block submission + * + * If the validation request itself fails, we allow the submission to + * continue. + */ +export const checkEmailDomain = async (email: string) => { + const [, domain] = email.split('@'); + // This should have already been checked, but it's an extra sanity check + if (!domain) { + throw AuthUiErrors.EMAIL_REQUIRED; + } + + if (topEmailDomains.has(domain)) { + // Glean / FXA-11292: email-domain-validation.skipped + return; + } + + // User could repeatedly submit the form + if (previousDomain === domain) { + // if the previous results is 'A' allow it + if (previousDomainResult === 'A') { + // Glean / FXA-11292: email-domain-validation.ignored + return; + } + throw AuthUiErrors.INVALID_EMAIL_DOMAIN; + } + + // Glean / FXA-11292: email-domain-validation.triggered + + let resp; + try { + resp = await resolveDomain(domain); + if (!resp) { + // Don't error and allow submission in case of server failure + return; + } + } catch (error) { + // Don't error and allow submission in case of server failure + return; + } + + const { result } = resp; + previousDomain = domain; + previousDomainResult = result; + + switch (result) { + case 'MX': + // Glean / FXA-11292: emailDomainValidation.success, email-domain-validation.success + return; + case 'A': + // Glean / FXA-11292: email-domain-validation.warn + throw AuthUiErrors.INVALID_EMAIL_DOMAIN; + case 'skip': + return; + default: + // Glean / FXA-11292: email-domain-validation.block + throw AuthUiErrors.INVALID_EMAIL_DOMAIN; + } +}; diff --git a/packages/fxa-settings/src/lib/error-utils.ts b/packages/fxa-settings/src/lib/error-utils.ts index 9f20d2cd2bc..8894b1863b6 100644 --- a/packages/fxa-settings/src/lib/error-utils.ts +++ b/packages/fxa-settings/src/lib/error-utils.ts @@ -173,3 +173,10 @@ export const getErrorFtlId = (err: { errno?: number; message?: string }) => { Sentry.captureMessage(logMessage); return ''; }; + +const NAMED_VARIABLE = /%\(([a-zA-Z]+)\)s/g; +export function interpolate(string: string, context: Record) { + return string.replace(NAMED_VARIABLE, (match, name) => { + return name in context ? context[name] : match; + }); +} diff --git a/packages/fxa-settings/src/lib/hooks.tsx b/packages/fxa-settings/src/lib/hooks.tsx index 8f704079221..69154d1b514 100644 --- a/packages/fxa-settings/src/lib/hooks.tsx +++ b/packages/fxa-settings/src/lib/hooks.tsx @@ -3,6 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useEffect, useRef } from 'react'; +import { useConfig } from '../models'; +import { isInReactExperiment } from './cache'; // Focus on the element that triggered some action after the first // argument changes from `false` to `true` unless a `triggerException` @@ -55,3 +57,13 @@ export function useChangeFocusEffect() { return elToFocus; } + +/* + * Temporary helper to check that emailFirstRoutes feature flag is on + * and (if not 100% rolled out) that the user is in the React experiment. + */ +export function useCheckReactEmailFirst() { + const config = useConfig(); + // TODO with FXA-11221 (100% prod rollout), remove isInReactExperiment() check + return config.showReactApp.emailFirstRoutes === true && isInReactExperiment(); +} diff --git a/packages/fxa-settings/src/lib/oauth/oauth-errors.ts b/packages/fxa-settings/src/lib/oauth/oauth-errors.ts index f33fb7347ea..7cd39da78b2 100644 --- a/packages/fxa-settings/src/lib/oauth/oauth-errors.ts +++ b/packages/fxa-settings/src/lib/oauth/oauth-errors.ts @@ -2,6 +2,8 @@ * 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 { interpolate } from '../error-utils'; + export type AuthError = { errno: number; message: string; @@ -203,13 +205,6 @@ export class OAuthError extends Error { } } -const NAMED_VARIABLE = /%\(([a-zA-Z]+)\)s/g; -function interpolate(string: string, context: Record) { - return string.replace(NAMED_VARIABLE, (match, name) => { - return name in context ? context[name] : match; - }); -} - export function normalizeXHRError(response: Response) { // TODO: Implement Auth Error Handling throw new Error('NOT YET IMPLEMENTED'); diff --git a/packages/fxa-settings/src/models/integrations/utils.ts b/packages/fxa-settings/src/models/integrations/utils.ts index e5c0382f0b3..e09cde7887c 100644 --- a/packages/fxa-settings/src/models/integrations/utils.ts +++ b/packages/fxa-settings/src/models/integrations/utils.ts @@ -10,3 +10,13 @@ export function isFirefoxService(service?: string) { service === OAuthNativeServices.Relay ); } + +const NO_LONGER_SUPPORTED_CONTEXTS = new Set([ + 'fx_desktop_v1', + 'fx_desktop_v2', + 'fx_firstrun_v2', + 'iframe', +]); +export function isUnsupportedContext(context?: string): boolean { + return !!context && NO_LONGER_SUPPORTED_CONTEXTS.has(context); +} diff --git a/packages/fxa-settings/src/models/mocks.tsx b/packages/fxa-settings/src/models/mocks.tsx index aca10d4c03c..ca84bda45b3 100644 --- a/packages/fxa-settings/src/models/mocks.tsx +++ b/packages/fxa-settings/src/models/mocks.tsx @@ -10,7 +10,7 @@ import { AppContextValue, defaultAppContext, SettingsContextValue, -} from '.'; +} from './contexts/AppContext'; import { createHistory, diff --git a/packages/fxa-settings/src/models/pages/index/index.ts b/packages/fxa-settings/src/models/pages/index/index.ts new file mode 100644 index 00000000000..bc3ccca4c27 --- /dev/null +++ b/packages/fxa-settings/src/models/pages/index/index.ts @@ -0,0 +1,5 @@ +/* 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/. */ + +export * from './query-params'; diff --git a/packages/fxa-settings/src/models/pages/index/query-params.ts b/packages/fxa-settings/src/models/pages/index/query-params.ts new file mode 100644 index 00000000000..2f25c961945 --- /dev/null +++ b/packages/fxa-settings/src/models/pages/index/query-params.ts @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { IsEmail, IsOptional } from 'class-validator'; +import { bind, ModelDataProvider } from '../../../lib/model-data'; + +export class IndexQueryParams extends ModelDataProvider { + @IsEmail() + @IsOptional() + @bind() + email: string = ''; +} diff --git a/packages/fxa-settings/src/models/pages/signup/query-params.ts b/packages/fxa-settings/src/models/pages/signup/query-params.ts index 0f216f706c4..1506825e0c3 100644 --- a/packages/fxa-settings/src/models/pages/signup/query-params.ts +++ b/packages/fxa-settings/src/models/pages/signup/query-params.ts @@ -7,12 +7,15 @@ import { bind, ModelDataProvider } from '../../../lib/model-data'; export class SignupQueryParams extends ModelDataProvider { // 'email' will be optional once the index page is converted to React - // and we pass it with router-state instead of a param, and `emailStatusChecked` - // can be removed + // and we pass it with router-state instead of a param @IsEmail() + @IsOptional() @bind() email: string = ''; + // When we get rid of Backbone email-first or React email-first has been + // rolled out for a while (to ensure we won't turn the feature off), this + // `emailStatusChecked` can be removed @IsOptional() @IsBoolean() @bind() diff --git a/packages/fxa-settings/src/pages/Index/container.tsx b/packages/fxa-settings/src/pages/Index/container.tsx new file mode 100644 index 00000000000..dbab176101e --- /dev/null +++ b/packages/fxa-settings/src/pages/Index/container.tsx @@ -0,0 +1,159 @@ +/* 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 } from '@reach/router'; +import { useNavigateWithQuery as useNavigate } from '../../lib/hooks/useNavigateWithQuery'; +import Index from '.'; +import { IndexContainerProps, LocationState } from './interfaces'; +import { useCallback, useEffect } from 'react'; +import { useAuthClient } from '../../models'; +import firefox from '../../lib/channels/firefox'; +import { AuthUiError, AuthUiErrors } from '../../lib/auth-errors/auth-errors'; +import { getHandledError } from '../../lib/error-utils'; +import { currentAccount } from '../../lib/cache'; +import { useValidatedQueryParams } from '../../lib/hooks/useValidate'; +import { IndexQueryParams } from '../../models/pages/index'; +import { isUnsupportedContext } from '../../models/integrations/utils'; +import { hardNavigate } from 'fxa-react/lib/utils'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; +import { checkEmailDomain } from '../../lib/email-domain-validator'; +import { isEmailMask, isEmailValid } from 'fxa-shared/email/helpers'; + +// TODO: remove this function, it's only here to make TS happy until +// we work on FXA-9757. errnos are always defined +function getErrorWithDefinedErrNo(error: AuthUiError) { + return { + ...error, + errno: error.errno!, + }; +} + +export const IndexContainer = ({ + integration, + serviceName, +}: IndexContainerProps & RouteComponentProps) => { + // TODO, more strict validation for bad oauth params, FXA-11297 + const authClient = useAuthClient(); + const navigate = useNavigate(); + const location = useLocation() as ReturnType & { + state?: LocationState; + }; + const { queryParamModel, validationError } = + useValidatedQueryParams(IndexQueryParams); + + const { prefillEmail, deleteAccountSuccess, hasBounced } = + location.state || {}; + + const isWebChannelIntegration = + integration.isSync() || integration.isDesktopRelay(); + + // Query param should take precedence + const email = queryParamModel.email || currentAccount()?.email; + const shouldRedirectToSignin = email && !prefillEmail; + + useEffect(() => { + if (shouldRedirectToSignin) { + navigate('/signin', { + state: { + email, + }, + }); + } + }); + + const signUpOrSignInHandler = useCallback( + async (email: string) => { + try { + if (!isEmailValid(email)) { + return { + error: getErrorWithDefinedErrNo(AuthUiErrors.EMAIL_REQUIRED), + }; + } + const { exists, hasLinkedAccount, hasPassword } = + await authClient.accountStatusByEmail(email, { + thirdPartyAuthStatus: true, + }); + if (!exists) { + if (isEmailMask(email)) { + return { + error: getErrorWithDefinedErrNo( + AuthUiErrors.EMAIL_MASK_NEW_ACCOUNT + ), + }; + // "@firefox" or "@firefox.com" email addresses are not valid + // at this time, therefore block the attempt. + // the added 'i' disallows uppercase letters + } else if (new RegExp('@firefox(\\.com)?$', 'i').test(email)) { + return { + error: getErrorWithDefinedErrNo( + AuthUiErrors.DIFFERENT_EMAIL_REQUIRED_FIREFOX_DOMAIN + ), + }; + } + // DNS lookup for MX record + await checkEmailDomain(email); + + navigate('/signup', { + state: { + email, + emailStatusChecked: true, + }, + }); + return { error: null }; + } else { + if (isWebChannelIntegration) { + const { ok } = await firefox.fxaCanLinkAccount({ email }); + if (!ok) { + return { + error: getErrorWithDefinedErrNo( + AuthUiErrors.USER_CANCELED_LOGIN + ), + }; + } + } + + navigate('/signin', { + state: { + email, + hasLinkedAccount, + hasPassword, + }, + }); + } + return { error: null }; + } catch (error) { + return getHandledError(error); + } + }, + [authClient, navigate, isWebChannelIntegration] + ); + + if (validationError) { + // TODO: should we bother to check for this? + // currently this is only a validation error for query param 'email', so do nothing? + // Look @ in FXA-11297 + } + + if (isUnsupportedContext(integration.data.context)) { + hardNavigate('/update_firefox', {}, true); + return ; + } + + if (shouldRedirectToSignin) { + return ; + } + + return ( + + ); +}; diff --git a/packages/fxa-settings/src/pages/Index/en.ftl b/packages/fxa-settings/src/pages/Index/en.ftl index ff7a63b59e0..ee826325d5d 100644 --- a/packages/fxa-settings/src/pages/Index/en.ftl +++ b/packages/fxa-settings/src/pages/Index/en.ftl @@ -3,6 +3,8 @@ index-header = Enter your email index-sync-header = Continue to your { -product-mozilla-account } index-sync-subheader = Sync your passwords, tabs, and bookmarks everywhere you use { -brand-firefox }. +index-relay-header = Create an email mask +index-relay-subheader = Please provide the email address where you’d like to forward emails from your masked email. # $serviceName - the service (e.g., Pontoon) that the user is signing into with a Mozilla account index-subheader-with-servicename = Continue to { $serviceName } index-subheader-with-logo = Continue to { $serviceLogo } @@ -11,3 +13,7 @@ index-cta = Sign up or sign in index-account-info = A { -product-mozilla-account } also unlocks access to more privacy-protecting products from { -brand-mozilla }. index-email-input = .label = Enter your email +# When users delete their Mozilla account inside account Settings, they are redirected to this page with a success message +index-account-delete-success = Account deleted successfully +# Displayed when users try to sign up for an account and their confirmation code email bounces +index-email-bounced = Your confirmation email was just returned. Mistyped email? diff --git a/packages/fxa-settings/src/pages/Index/index.stories.tsx b/packages/fxa-settings/src/pages/Index/index.stories.tsx index 3828846710a..6e0f7a2e123 100644 --- a/packages/fxa-settings/src/pages/Index/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Index/index.stories.tsx @@ -9,7 +9,7 @@ import { withLocalization } from 'fxa-react/lib/storybooks'; import { IndexProps } from './interfaces'; import { createMockIndexOAuthIntegration, - createMockIndexSyncIntegration, + createMockIndexOAuthNativeIntegration, Subject, } from './mocks'; import { @@ -17,6 +17,7 @@ import { POCKET_CLIENTIDS, } from '../../models/integrations/client-matching'; import { MozServices } from '../../lib/types'; +import { MOCK_EMAIL } from '../mocks'; export default { title: 'Pages/Index', @@ -33,8 +34,27 @@ const storyWithProps = ({ export const Default = storyWithProps(); +export const WithBouncedEmail = storyWithProps({ + hasBounced: true, + prefillEmail: MOCK_EMAIL, +}); + +export const WithServiceRelayIntegration = storyWithProps({ + integration: createMockIndexOAuthNativeIntegration({ + isDesktopRelay: true, + }), +}); + +export const WithPrefilledEmail = storyWithProps({ + prefillEmail: MOCK_EMAIL, +}); + +export const WithDeleteAccountSuccess = storyWithProps({ + deleteAccountSuccess: true, +}); + export const Sync = storyWithProps({ - integration: createMockIndexSyncIntegration(), + integration: createMockIndexOAuthNativeIntegration(), serviceName: MozServices.FirefoxSync, }); diff --git a/packages/fxa-settings/src/pages/Index/index.test.tsx b/packages/fxa-settings/src/pages/Index/index.test.tsx index cb284cc5e05..15dd1922d68 100644 --- a/packages/fxa-settings/src/pages/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Index/index.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { createMockIndexOAuthIntegration, - createMockIndexSyncIntegration, + createMockIndexOAuthNativeIntegration, Subject, } from './mocks'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; @@ -27,6 +27,14 @@ function thirdPartyAuthWithSeparatorRendered() { name: /Continue with Apple/, }); } +function thirdPartyAuthNotRendered() { + expect( + screen.queryByRole('button', { name: /Continue with Google/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Continue with Apple/ }) + ).not.toBeInTheDocument(); +} describe('Index page', () => { it('renders as expected with web integration', () => { @@ -51,7 +59,7 @@ describe('Index page', () => { it('renders as expected when sync', () => { renderWithLocalizationProvider( ); @@ -60,12 +68,7 @@ describe('Index page', () => { screen.getByText(syncText); screen.getByText(syncTextSecondary); - expect( - screen.queryByRole('button', { name: /Continue with Google/ }) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: /Continue with Apple/ }) - ).not.toBeInTheDocument(); + thirdPartyAuthNotRendered(); expect( screen.getByRole('link', { @@ -74,6 +77,37 @@ describe('Index page', () => { ).toHaveAttribute('href', '/legal/terms'); }); + it('renders as expected with service=relay', () => { + renderWithLocalizationProvider( + + ); + + screen.getByRole('heading', { name: 'Create an email mask' }); + screen.getByText( + 'Please provide the email address where you’d like to forward emails from your masked email.' + ); + screen.getByRole('button', { name: 'Sign up or sign in' }); + + expect(screen.queryByText(syncText)).not.toBeInTheDocument(); + expect(screen.queryByText(syncTextSecondary)).not.toBeInTheDocument(); + + thirdPartyAuthNotRendered(); + + expect( + screen.getAllByRole('link', { + name: /Terms of Service/, + })[0] + ).toHaveAttribute( + 'href', + 'https://www.mozilla.org/about/legal/terms/subscription-services/' + ); + }); + it('renders as expected when client is Pocket', () => { renderWithLocalizationProvider( { + signUpOrSignInHandler, + prefillEmail, + deleteAccountSuccess, + hasBounced, +}: IndexProps) => { const clientId = integration.getClientId(); const isSync = integration.isSync(); const isDesktopRelay = integration.isDesktopRelay(); @@ -29,6 +37,91 @@ export const Index = ({ const isPocketClient = isOAuth && isClientPocket(clientId); const isMonitorClient = isOAuth && isClientMonitor(clientId); const isRelayClient = isOAuth && isClientRelay(clientId); + + const ftlMsgResolver = useFtlMsgResolver(); + const [errorBannerMessage, setErrorBannerMessage] = useState(''); + const [successBannerMessage, setSuccessBannerMessage] = useState( + deleteAccountSuccess + ? ftlMsgResolver.getMsg( + 'index-account-delete-success', + 'Account deleted successfully' + ) + : undefined + ); + const [tooltipErrorText, setTooltipErrorText] = useState( + hasBounced + ? ftlMsgResolver.getMsg( + 'auth-errors-1018', + 'Your confirmation email was just returned. Mistyped email?' + ) + : undefined + ); + + useEffect(() => { + // Note we might not need this later due to automatic page load events, + // but it's here for now to match parity with Backbone. This will be closely + // monitored for the `service=relay` flow for some time. + GleanMetrics.emailFirst.view(); + }, []); + + const { handleSubmit, register } = useForm({ + mode: 'onChange', + criteriaMode: 'all', + defaultValues: { + email: '', + }, + }); + + const onSubmit = async ({ email }: IndexFormData) => { + // This function handles navigation on success + const { error } = await signUpOrSignInHandler(email); + if (error) { + switch (error.errno) { + case AuthUiErrors.EMAIL_REQUIRED.errno: + setTooltipErrorText( + ftlMsgResolver.getMsg( + 'auth-error-1011', + AuthUiErrors.EMAIL_REQUIRED.message + ) + ); + break; + case AuthUiErrors.EMAIL_MASK_NEW_ACCOUNT.errno: + setTooltipErrorText( + ftlMsgResolver.getMsg( + 'auth-error-1066', + AuthUiErrors.EMAIL_MASK_NEW_ACCOUNT.message + ) + ); + break; + case AuthUiErrors.DIFFERENT_EMAIL_REQUIRED_FIREFOX_DOMAIN.errno: + setTooltipErrorText( + ftlMsgResolver.getMsg( + 'auth-error-1020', + AuthUiErrors.DIFFERENT_EMAIL_REQUIRED_FIREFOX_DOMAIN.message + ) + ); + break; + case AuthUiErrors.INVALID_EMAIL_DOMAIN.errno: { + const [, domain] = email.split('@'); + setTooltipErrorText( + ftlMsgResolver.getMsg( + 'auth-error-1064', + interpolate(AuthUiErrors.INVALID_EMAIL_DOMAIN.message, { + domain, + }), + { domain } + ) + ); + break; + } + default: + setErrorBannerMessage( + getLocalizedErrorMessage(ftlMsgResolver, error) + ); + } + } + }; + return ( {isSync ? ( @@ -45,6 +138,18 @@ export const Index = ({

+ ) : isDesktopRelay ? ( + <> +

+ Create an email mask +

+

+ + Please provide the email address where you’d like to forward + emails from your masked email. + +

+ ) : ( )} - - - -
- -
+ {errorBannerMessage && ( + + )} + {successBannerMessage && ( + + )} + +
+ + { + if (errorBannerMessage || successBannerMessage) { + // TODO improve this, needs height or some animation + setErrorBannerMessage(''); + setSuccessBannerMessage(''); + } + if (tooltipErrorText) { + setTooltipErrorText(''); + } + }} + /> + +
+ +
+
+ {isSync ? (

@@ -71,7 +211,7 @@ export const Index = ({

) : ( - + !isDesktopRelay && )} ; -export interface IndexProps { +export interface IndexContainerProps { integration: IndexIntegration; serviceName: MozServices; } + +export interface LocationState { + prefillEmail?: string; + deleteAccountSuccess?: boolean; + hasBounced?: boolean; +} + +export interface IndexProps extends LocationState { + integration: IndexIntegration; + serviceName: MozServices; + signUpOrSignInHandler: ( + email: string + ) => Promise<{ error: HandledError | null }>; +} + +export interface IndexFormData { + email: string; +} diff --git a/packages/fxa-settings/src/pages/Index/mocks.tsx b/packages/fxa-settings/src/pages/Index/mocks.tsx index 309461e2069..544081e90bc 100644 --- a/packages/fxa-settings/src/pages/Index/mocks.tsx +++ b/packages/fxa-settings/src/pages/Index/mocks.tsx @@ -9,6 +9,7 @@ import { IntegrationType } from '../../models'; import { IndexIntegration } from './interfaces'; import Index from '.'; import { MOCK_CLIENT_ID } from '../mocks'; +import { Constants } from '../../lib/constants'; export function createMockIndexOAuthIntegration({ clientId = MOCK_CLIENT_ID, @@ -18,14 +19,26 @@ export function createMockIndexOAuthIntegration({ isSync: () => false, getClientId: () => clientId, isDesktopRelay: () => false, + data: { + context: '', + }, }; } -export function createMockIndexSyncIntegration(): IndexIntegration { +export function createMockIndexOAuthNativeIntegration({ + isSync = true, + isDesktopRelay = false, +}: { + isSync?: boolean; + isDesktopRelay?: boolean; +} = {}): IndexIntegration { return { type: IntegrationType.OAuthNative, - isSync: () => true, + isSync: () => isSync, getClientId: () => MOCK_CLIENT_ID, - isDesktopRelay: () => false, + isDesktopRelay: () => isDesktopRelay, + data: { + context: Constants.OAUTH_WEBCHANNEL_CONTEXT, + }, }; } @@ -35,20 +48,34 @@ export function createMockIndexWebIntegration(): IndexIntegration { isSync: () => false, getClientId: () => undefined, isDesktopRelay: () => false, + data: { + context: '', + }, }; } export const Subject = ({ integration = createMockIndexWebIntegration(), serviceName = MozServices.Default, + prefillEmail, + deleteAccountSuccess, + hasBounced, }: { integration?: IndexIntegration; serviceName?: MozServices; + prefillEmail?: string; + deleteAccountSuccess?: boolean; + hasBounced?: boolean; }) => { return ( ({ error: null })} {...{ + prefillEmail, + deleteAccountSuccess, + hasBounced, integration, serviceName, }} diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx index 16cb236ced9..1cc03cf0e07 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx @@ -15,6 +15,8 @@ import { EmailCodeImage } from '../../../components/images'; import GleanMetrics from '../../../lib/glean'; import Banner, { ResendCodeSuccessBanner } from '../../../components/Banner'; import { HeadingPrimary } from '../../../components/HeadingPrimary'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; const ConfirmResetPassword = ({ clearBanners, @@ -32,6 +34,8 @@ const ConfirmResetPassword = ({ const ftlMsgResolver = useFtlMsgResolver(); const location = useLocation(); + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const spanElement = {email}; @@ -103,19 +107,28 @@ const ConfirmResetPassword = ({ onClick={(e) => { e.preventDefault(); GleanMetrics.passwordReset.emailConfirmationDifferentAccount(); - const params = new URLSearchParams(location.search); - // Tell content-server to stay on index and prefill the email - params.set('prefillEmail', email); - // Passing back the 'email' param causes various behaviors in - // content-server since it marks the email as "coming from a RP". - // Also remove other params that are passed when coming - // from content-server to Backbone, see Signup container component - // for more info. - params.delete('email'); - params.delete('hasLinkedAccount'); - params.delete('hasPassword'); - params.delete('showReactApp'); - hardNavigate(`/?${params.toString()}`); + + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + prefillEmail: email, + }, + }); + } else { + const params = new URLSearchParams(location.search); + // Tell content-server to stay on index and prefill the email + params.set('prefillEmail', email); + // Passing back the 'email' param causes various behaviors in + // content-server since it marks the email as "coming from a RP". + // Also remove other params that are passed when coming + // from content-server to Backbone, see Signup container component + // for more info. + params.delete('email'); + params.delete('hasLinkedAccount'); + params.delete('hasPassword'); + params.delete('showReactApp'); + hardNavigate(`/?${params.toString()}`); + } }} > Use a different account diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/index.tsx index a5fc5047df7..0aa102ed60d 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmTotpResetPassword/index.tsx @@ -12,6 +12,8 @@ import FormVerifyCode, { FormAttributes, } from '../../../components/FormVerifyCode'; import { HeadingPrimary } from '../../../components/HeadingPrimary'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; export type ConfirmTotpResetPasswordProps = { verifyCode: (code: string) => Promise; @@ -28,6 +30,8 @@ const ConfirmTotpResetPassword = ({ }: ConfirmTotpResetPasswordProps) => { const ftlMsgResolver = useFtlMsgResolver(); const [showRecoveryCode, setShowRecoveryCode] = useState(false); + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const totpFormAttributes: FormAttributes = { inputFtlId: 'confirm-totp-reset-password-input-label-v2', @@ -140,8 +144,12 @@ const ConfirmTotpResetPassword = ({ className="link-blue text-sm" data-glean-id="reset_password_confirm_totp_use_different_account_button" onClick={() => { - // Navigate to email first page and keep search params - hardNavigate('/', {}, true); + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + // Navigate to email first page and keep search params + hardNavigate('/', {}, true); + } }} > diff --git a/packages/fxa-settings/src/pages/Signin/SigninBounced/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninBounced/index.tsx index 38b1ecaca95..ae2dd69c8ab 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninBounced/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninBounced/index.tsx @@ -8,11 +8,13 @@ import { usePageViewEvent, logViewEvent } from '../../../lib/metrics'; import { ReactComponent as EmailBounced } from './graphic_email_bounced.svg'; import { FtlMsg, hardNavigate } from 'fxa-react/lib/utils'; import { useFtlMsgResolver } from '../../../models/hooks'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; import AppLayout from '../../../components/AppLayout'; import { REACT_ENTRYPOINT } from '../../../constants'; import CardHeader from '../../../components/CardHeader'; import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; export type SigninBouncedProps = { email?: string; @@ -32,6 +34,8 @@ const SigninBounced = ({ usePageViewEvent(viewName, REACT_ENTRYPOINT); const ftlMessageResolver = useFtlMsgResolver(); const backText = ftlMessageResolver.getMsg('back', 'Back'); + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const handleNavigationBack = (event: any) => { logViewEvent(viewName, 'link.back', REACT_ENTRYPOINT); @@ -44,15 +48,19 @@ const SigninBounced = ({ useEffect(() => { if (!email) { - hardNavigate('/', {}, true); + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + hardNavigate('/', {}, true); + } } - }, [email]); + }, [email, navigate, shouldUseReactEmailFirst]); const createAccountHandler = () => { logViewEvent(viewName, 'link.create-account', REACT_ENTRYPOINT); localStorage.removeItem('__fxa_storage.accounts'); sessionStorage.clear(); - hardNavigate('/signup', {}, true); + navigate('/signup'); }; return ( diff --git a/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx index c7efbad9035..def8c1ffd0f 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx @@ -21,6 +21,7 @@ import { useWebRedirect } from '../../../lib/hooks/useWebRedirect'; import { useEffect, useState } from 'react'; import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; import { SensitiveData } from '../../../lib/sensitive-data-client'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; export type SigninPushCodeContainerProps = { integration: Integration; @@ -33,6 +34,7 @@ export const SigninPushCodeContainer = ({ }: SigninPushCodeContainerProps & RouteComponentProps) => { const authClient = useAuthClient(); const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler( authClient, integration @@ -73,7 +75,11 @@ export const SigninPushCodeContainer = ({ } if (!signinState) { - hardNavigate('/', {}, true); + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + hardNavigate('/', {}, true); + } return ; } diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx index 8c2e5bfd09c..06e41fccd31 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx @@ -25,7 +25,8 @@ import OAuthDataError from '../../../components/OAuthDataError'; import { getHandledError } from '../../../lib/error-utils'; import { SensitiveData } from '../../../lib/sensitive-data-client'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; -import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; type SigninRecoveryCodeLocationState = { signinState: SigninLocationState; @@ -48,7 +49,8 @@ export const SigninRecoveryCodeContainer = ({ (useLocation() as ReturnType & { state: SigninRecoveryCodeLocationState; }) || {}; - const navigateWithQuery = useNavigateWithQuery(); + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const signinState = getSigninState(location.state?.signinState); const lastFourPhoneDigits = location.state?.lastFourPhoneDigits; const sensitiveDataClient = useSensitiveDataClient(); @@ -89,14 +91,14 @@ export const SigninRecoveryCodeContainer = ({ } try { await authClient.recoveryPhoneSigninSendCode(signinState.sessionToken); - navigateWithQuery('/signin_recovery_phone', { + navigate('/signin_recovery_phone', { state: { signinState, lastFourPhoneDigits }, }); return; } catch (error) { const { error: handledError } = getHandledError(error); if (handledError.errno === AuthUiErrors.INVALID_TOKEN.errno) { - navigateWithQuery('/signin'); + navigate('/signin'); return; } return handledError; @@ -111,7 +113,11 @@ export const SigninRecoveryCodeContainer = ({ } if (!signinState) { - hardNavigate('/', {}, true); + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + hardNavigate('/', {}, true); + } return ; } diff --git a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx index 7b2e7b5548f..9dfc6225532 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx @@ -35,6 +35,7 @@ import { PASSWORD_CHANGE_FINISH_MUTATION, PASSWORD_CHANGE_START_MUTATION, } from '../gql'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; // The email with token code (verifyLoginCodeEmail) is sent on `/signin` // submission if conditions are met. @@ -48,6 +49,7 @@ const SigninTokenCodeContainer = ({ const location = useLocation() as ReturnType & { state?: SigninLocationState; }; + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const signinState = getSigninState(location.state); const sensitiveDataClient = useSensitiveDataClient(); @@ -94,7 +96,11 @@ const SigninTokenCodeContainer = ({ }, [authClient, signinState]); if (!signinState || !signinState.sessionToken) { - hardNavigate('/', {}, true); + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + hardNavigate('/', {}, true); + } return ; } diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx index 10aa8a18b51..5e877aea10f 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx @@ -43,6 +43,8 @@ import { } from '../gql'; import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; export type SigninTotpCodeContainerProps = { integration: Integration; @@ -69,6 +71,8 @@ export const SigninTotpCodeContainer = ({ const { queryParamModel } = useValidatedQueryParams(SigninQueryParams); const { service } = queryParamModel; + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const webRedirectCheck = useWebRedirect(integration.data.redirectTo); @@ -160,7 +164,11 @@ export const SigninTotpCodeContainer = ({ (signinState.verificationMethod && signinState.verificationMethod !== VerificationMethods.TOTP_2FA) ) { - hardNavigate('/', {}, true); + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + hardNavigate('/', {}, true); + } return ; } diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx index a2e0d8788aa..f8c200395a8 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx @@ -11,6 +11,7 @@ import { MozServices } from '../../../lib/types'; import AppLayout from '../../../components/AppLayout'; import GleanMetrics from '../../../lib/glean'; import { SigninIntegration, SigninLocationState } from '../interfaces'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; import { handleNavigation } from '../utils'; import { FinishOAuthFlowHandler } from '../../../lib/oauth/hooks'; import { storeAccountData } from '../../../lib/storage-utils'; @@ -23,6 +24,7 @@ import Banner from '../../../components/Banner'; import { SensitiveData } from '../../../lib/sensitive-data-client'; import { HeadingPrimary } from '../../../components/HeadingPrimary'; import FormVerifyTotp from '../../../components/FormVerifyTotp'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; // TODO: show a banner success message if a user is coming from reset password // in FXA-6491. This differs from content-server where currently, users only @@ -52,6 +54,8 @@ export const SigninTotpCode = ({ const config = useConfig(); const ftlMsgResolver = useFtlMsgResolver(); const location = useLocation(); + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const [bannerError, setBannerError] = useState(''); @@ -176,19 +180,28 @@ export const SigninTotpCode = ({ data-glean-id="login_totp_code_different_account_link" onClick={(e) => { e.preventDefault(); - const params = new URLSearchParams(location.search); - // Tell content-server to stay on index and prefill the email - params.set('prefillEmail', email); - // Passing back the 'email' param causes various behaviors in - // content-server since it marks the email as "coming from a RP". - // Also remove other params that are passed when coming - // from content-server to Backbone, see Signup container component - // for more info. - params.delete('email'); - params.delete('hasLinkedAccount'); - params.delete('hasPassword'); - params.delete('showReactApp'); - hardNavigate(`/?${params.toString()}`); + + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + prefillEmail: email, + }, + }); + } else { + const params = new URLSearchParams(location.search); + // Tell content-server to stay on index and prefill the email + params.set('prefillEmail', email); + // Passing back the 'email' param causes various behaviors in + // content-server since it marks the email as "coming from a RP". + // Also remove other params that are passed when coming + // from content-server to Backbone, see Signup container component + // for more info. + params.delete('email'); + params.delete('hasLinkedAccount'); + params.delete('hasPassword'); + params.delete('showReactApp'); + hardNavigate(`/?${params.toString()}`); + } }} > Use a different account diff --git a/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx index dcdb86eb609..dee3753c3b9 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx @@ -52,6 +52,8 @@ import { SignInOptions } from 'fxa-auth-client/browser'; import { SensitiveData } from '../../../lib/sensitive-data-client'; import { isFirefoxService } from '../../../models/integrations/utils'; import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; +import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery'; export const SigninUnblockContainer = ({ integration, @@ -62,6 +64,8 @@ export const SigninUnblockContainer = ({ } & RouteComponentProps) => { const authClient = useAuthClient(); const ftlMsgResolver = useFtlMsgResolver(); + const navigate = useNavigate(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const location = useLocation() as ReturnType & { state: SigninUnblockLocationState; @@ -217,7 +221,11 @@ export const SigninUnblockContainer = ({ } if (!email || !password) { - hardNavigate('/', {}, true); + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + hardNavigate('/', {}, true); + } return ; } return ( diff --git a/packages/fxa-settings/src/pages/Signin/container.test.tsx b/packages/fxa-settings/src/pages/Signin/container.test.tsx index 31e0d323fce..2602fed2740 100644 --- a/packages/fxa-settings/src/pages/Signin/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/container.test.tsx @@ -135,6 +135,12 @@ function applyDefaultMocks() { mockSentryModule(); } +let mockUseCheckReactEmailFirst = jest.fn().mockReturnValue(true); +jest.mock('../../lib/hooks', () => ({ + __esModule: true, + ...jest.requireActual('../../lib/hooks'), + useCheckReactEmailFirst: () => mockUseCheckReactEmailFirst(), +})); jest.mock('../../models', () => { return { ...jest.requireActual('../../models'), @@ -761,7 +767,7 @@ describe('signin container', () => { }); }); - it('returns expected error when fxaCanLinkAccount response is !ok', async () => { + it('returns expected error when fxaCanLinkAccount response is ok: false', async () => { (firefox.fxaCanLinkAccount as jest.Mock).mockImplementationOnce( async () => ({ ok: false, diff --git a/packages/fxa-settings/src/pages/Signin/container.tsx b/packages/fxa-settings/src/pages/Signin/container.tsx index 447777836b4..6830e1950f4 100644 --- a/packages/fxa-settings/src/pages/Signin/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/container.tsx @@ -2,7 +2,8 @@ * 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 { RouteComponentProps, useLocation } from '@reach/router'; +import { useNavigateWithQuery as useNavigate } from '../../lib/hooks/useNavigateWithQuery'; import Signin from '.'; import { Integration, @@ -68,9 +69,29 @@ import { SensitiveDataClient, } from '../../lib/sensitive-data-client'; import { Constants } from '../../lib/constants'; -import { isFirefoxService } from '../../models/integrations/utils'; +import { + isFirefoxService, + isUnsupportedContext, +} from '../../models/integrations/utils'; import { GqlKeyStretchUpgrade } from '../../lib/gql-key-stretch-upgrade'; import { setCurrentAccount } from '../../lib/storage-utils'; +import { useCheckReactEmailFirst } from '../../lib/hooks'; + +/* + * In content-server, the `email` param is optional. If it's provided, we + * check against it to see if the account exists and if it doesn't, we redirect + * users to `/signup`. + * + * In the React version, we're temporarily always passing the `email` param over + * from the Backbone index page until the index page is converted over, in which case + * we can pass the param with router state. Since we already perform this account exists + * (account status) check on the Backbone index page, which is rate limited since it doesn't + * require a session token, we also temporarily pass email status params to 1) signal not to + * perform the check again but also because 2) these params are needed to conditionally + * display UI in signin. If no status params are passed and `email` is, or we read the + * email from local storage, we perform the check and redirect existing user emails to + * `/signup` to match content-server functionality. + */ function getAccountInfo(email?: string) { const storedLocalAccount = currentAccount() || lastStoredAccount(); @@ -118,6 +139,7 @@ const SigninContainer = ({ }; const session = useSession(); const sensitiveDataClient = useSensitiveDataClient(); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const { queryParamModel, validationError } = useValidatedQueryParams(SigninQueryParams); @@ -142,6 +164,12 @@ const SigninContainer = ({ const { localizedSuccessBannerHeading, localizedSuccessBannerDescription } = successBanner || {}; + // If hasLinkedAccount is defined from location state (React email-first), or query + // params (Backbone email-first) then we know the user came from email-first + const originFromEmailFirst = !!( + hasLinkedAccountFromLocationState || queryParamModel.hasLinkedAccount + ); + const [accountStatus, setAccountStatus] = useState({ hasLinkedAccount: // TODO: in FXA-9177, retrieve hasLinkedAccount and hasPassword from Apollo cache (not state) @@ -164,10 +192,9 @@ const SigninContainer = ({ useEffect(() => { (async () => { const queryParams = new URLSearchParams(location.search); - // Tweak this once index page is converted to React if (!validationError && email) { - // if you directly hit /signin with email param or we read from localstorage - // this means the account status hasn't been checked + // if you directly hit /signin with email param, or we read from localstorage + // (on this page or email-first) this means the account status hasn't been checked if ( accountStatus.hasLinkedAccount === undefined || accountStatus.hasPassword === undefined @@ -178,11 +205,18 @@ const SigninContainer = ({ thirdPartyAuthStatus: true, }); if (!exists) { - // For now, just pass back emailStatusChecked. When we convert the Index page - // we'll want to read from router state. - queryParams.set('email', email); - queryParams.set('emailStatusChecked', 'true'); - navigate(`/signup?${queryParams}`); + if (shouldUseReactEmailFirst) { + navigate('/signup', { + state: { + email, + emailStatusChecked: true, + }, + }); + } else { + queryParams.set('email', email); + queryParams.set('emailStatusChecked', 'true'); + navigate(`/signup?${queryParams}`); + } } else { // TODO: in FXA-9177, also set hasLinkedAccount and hasPassword in Apollo cache setAccountStatus({ @@ -191,24 +225,40 @@ const SigninContainer = ({ }); } } catch (error) { - // Passing back the 'email' param causes various behaviors in - // content-server since it marks the email as "coming from a RP". - queryParams.delete('email'); - if (isEmailValid(email)) { - queryParams.set('prefillEmail', email); + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + prefillEmail: email, + }, + }); + } else { + // Passing back the 'email' param causes various behaviors in + // content-server since it marks the email as "coming from a RP". + queryParams.delete('email'); + if (isEmailValid(email)) { + queryParams.set('prefillEmail', email); + } + hardNavigate(`/?${queryParams}`); } - hardNavigate(`/?${queryParams}`); } } } else { - // Passing back the 'email' param causes various behaviors in - // content-server since it marks the email as "coming from a RP". - queryParams.delete('email'); - if (email && isEmailValid(email)) { - queryParams.set('prefillEmail', email); + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + prefillEmail: email, + }, + }); + } else { + // Passing back the 'email' param causes various behaviors in + // content-server since it marks the email as "coming from a RP". + queryParams.delete('email'); + if (email && isEmailValid(email)) { + queryParams.set('prefillEmail', email); + } + const optionalParams = queryParams.size > 0 ? `?${queryParams}` : ''; + hardNavigate(`/${optionalParams}`); } - const optionalParams = queryParams.size > 0 ? `?${queryParams}` : ''; - hardNavigate(`/${optionalParams}`); } })(); // Only run this on initial render @@ -238,16 +288,12 @@ const SigninContainer = ({ const beginSigninHandler: BeginSigninHandler = useCallback( async (email: string, password: string) => { - // If queryParamModel.hasLinkedAccount is defined, then we know the user - // came from email-first and was already prompted with the sync merge + // If the user was on email-first they were already prompted with the sync merge // warning. The browser will automatically respond with { ok: true } without // prompting the user if it matches the email the browser has data for. if ( - // Currently for email-first, we send this if `context=oauth_webchannel_v1`. - // Let's check that here too (TBD if we want this for isDesktopRelay; if not, - // we'll remove). (integration.isSync() || integration.isDesktopRelay()) && - queryParamModel.hasLinkedAccount === undefined + !originFromEmailFirst ) { const { ok } = await firefox.fxaCanLinkAccount({ email }); if (!ok) { @@ -387,9 +433,9 @@ const SigninContainer = ({ passwordChangeStart, wantsKeys, flowQueryParams, - queryParamModel.hasLinkedAccount, authClient, sensitiveDataClient, + originFromEmailFirst, ] ); @@ -500,6 +546,11 @@ const SigninContainer = ({ return ; } + if (isUnsupportedContext(integration.data.context)) { + hardNavigate('/update_firefox', {}, true); + return ; + } + return ( { e.preventDefault(); GleanMetrics.login.diffAccountLinkClick(); - const params = new URLSearchParams(location.search); - // Tell content-server to stay on index and prefill the email - params.set('prefillEmail', email); - // Passing back the 'email' param causes various behaviors in - // content-server since it marks the email as "coming from a RP". - // Also remove other params that are passed when coming - // from content-server to Backbone, see Signup container component - // for more info. - params.delete('email'); - params.delete('hasLinkedAccount'); - params.delete('hasPassword'); - params.delete('showReactApp'); - params.delete('login_hint'); - hardNavigate(`/?${params.toString()}`); + + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + prefillEmail: email, + }, + }); + } else { + const params = new URLSearchParams(location.search); + // Tell content-server to stay on index and prefill the email + params.set('prefillEmail', email); + // Passing back the 'email' param causes various behaviors in + // content-server since it marks the email as "coming from a RP". + // Also remove other params that are passed when coming + // from content-server to Backbone, see Signup container component + // for more info. + params.delete('email'); + params.delete('hasLinkedAccount'); + params.delete('hasPassword'); + params.delete('showReactApp'); + params.delete('login_hint'); + hardNavigate(`/?${params.toString()}`); + } }} > Use a different account diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx index 53c9b6fe0c1..9dd51a35f18 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx @@ -24,6 +24,7 @@ import { EMAIL_BOUNCE_STATUS_QUERY } from './gql'; import OAuthDataError from '../../../components/OAuthDataError'; import { QueryParams } from '../../..'; import { SensitiveData } from '../../../lib/sensitive-data-client'; +import { useCheckReactEmailFirst } from '../../../lib/hooks'; export const POLL_INTERVAL = 5000; @@ -56,6 +57,7 @@ const SignupConfirmCodeContainer = ({ const sensitiveDataClient = useSensitiveDataClient(); const { keyFetchToken, unwrapBKey } = sensitiveDataClient.getDataType(SensitiveData.Key.Auth) || {}; + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); const { oAuthKeysCheckError } = useOAuthKeysCheck( integration, @@ -131,13 +133,37 @@ const SignupConfirmCodeContainer = ({ const hasBounced = true; // if arriving from signup, return to '/' and allow user to signup with another email if (origin === 'signup') { - navigateToContentServer('/', hasBounced); + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + hasBounced, + prefillEmail: email, + }, + }); + } else { + navigateToContentServer('/', hasBounced); + } } else { // if not arriving from signup, redirect to signin_bounced for support info - navigateToContentServer('/signin_bounced', hasBounced); + if (shouldUseReactEmailFirst) { + navigate('/signin_bounced', { + state: { + hasBounced, + }, + }); + } else { + navigateToContentServer('/signin_bounced', hasBounced); + } } } - }, [data, origin, navigate, navigateToContentServer]); + }, [ + data, + origin, + navigate, + navigateToContentServer, + shouldUseReactEmailFirst, + email, + ]); // TODO: This check and related test can be moved up the tree to the App component, // where a missing integration should be caught and handled. diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx index 3e3df4bdf50..fb516e20983 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx @@ -178,7 +178,7 @@ const ConfirmSignupCode = ({ // Params are included to eventually allow for redirect to RP after 2FA setup if (integration.wantsTwoStepAuthentication()) { - hardNavigate('oauth/signin', {}, true); + navigate('oauth/signin'); return; } else { const { redirect, code, state, error } = await finishOAuthFlowHandler( diff --git a/packages/fxa-settings/src/pages/Signup/container.test.tsx b/packages/fxa-settings/src/pages/Signup/container.test.tsx index 92dfb6a1c5f..e6d3379068a 100644 --- a/packages/fxa-settings/src/pages/Signup/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signup/container.test.tsx @@ -245,25 +245,6 @@ describe('sign-up-container', () => { }); }); - describe('loading-states', () => { - beforeEach(() => { - // TIP - In this case, we want to override the previous behavior. We can do this - // easily in a before each or even within a test block. - (useAuthClient as jest.Mock).mockImplementation(() => { - let client = new AuthClient('localhost:9000', { keyStretchVersion: 1 }); - client.accountStatusByEmail = jest - .fn() - .mockReturnValue(new Promise(() => {})); - return client; - }); - }); - - it('shows loading until account status query resolves', async () => { - await render('loading spinner mock'); - expect(screen.queryByText('signup mock')).toBeNull(); - }); - }); - describe('error-states', () => { it('handles invalid email', async () => { // In this case want to mimic a bad email value diff --git a/packages/fxa-settings/src/pages/Signup/container.tsx b/packages/fxa-settings/src/pages/Signup/container.tsx index f0388e1a37f..0603b56c9bb 100644 --- a/packages/fxa-settings/src/pages/Signup/container.tsx +++ b/packages/fxa-settings/src/pages/Signup/container.tsx @@ -17,7 +17,7 @@ import { SignupIntegration, } from './interfaces'; import { BEGIN_SIGNUP_MUTATION } from './gql'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; import { getCredentials, getCredentialsV2, @@ -32,6 +32,7 @@ import { queryParamsToMetricsContext } from '../../lib/metrics'; import { QueryParams } from '../..'; import { isFirefoxService } from '../../models/integrations/utils'; import useSyncEngines from '../../lib/hooks/useSyncEngines'; +import { useCheckReactEmailFirst } from '../../lib/hooks'; /* * In content-server, the `email` param is optional. If it's provided, we @@ -57,6 +58,7 @@ import useSyncEngines from '../../lib/hooks/useSyncEngines'; type LocationState = { emailStatusChecked?: boolean; + email?: string; }; const SignupContainer = ({ @@ -73,14 +75,20 @@ const SignupContainer = ({ const location = useLocation() as ReturnType & { state: LocationState; }; - const { emailStatusChecked } = location.state || {}; const { queryParamModel, validationError } = useValidatedQueryParams(SignupQueryParams); + // emailStatusChecked is passed as a query param when coming from Backbone + // email-first, but location state when coming from React email-first. + // We can remove the query param bits once we're confidently at 100% roll out + // for React. + // emailStatusChecked can also be passed from React Signin when users hit /signin + // with an email query param that we already determined doesn't exist. + const emailStatusChecked = + queryParamModel.emailStatusChecked || location.state?.emailStatusChecked; + const email = queryParamModel.email || location.state?.email; + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); - // Since we may perform an async call on initial render that can affect what is rendered, - // return a spinner on first render. - const [showLoadingSpinner, setShowLoadingSpinner] = useState(true); const wantsKeys = integration.wantsKeys(); // TODO: in PostVerify/SetPassword we call this and handle web channel messaging @@ -91,15 +99,7 @@ const SignupContainer = ({ useEffect(() => { (async () => { - // Modify this once index is converted to React - // emailStatusChecked can be passed from React Signin when users hit /signin - // with an email query param that we already determined doesn't exist. - // It's supplied by Backbone when going from Backbone Index page to React signup. - if ( - !validationError && - !queryParamModel.emailStatusChecked && - !emailStatusChecked - ) { + if (!validationError && !emailStatusChecked) { const { exists, hasLinkedAccount, hasPassword } = await authClient.accountStatusByEmail(queryParamModel.email, { thirdPartyAuthStatus: true, @@ -109,17 +109,16 @@ const SignupContainer = ({ navigate(`/signin`, { replace: true, state: { - email: queryParamModel.email, + email, hasLinkedAccount, hasPassword, }, }); } else { - hardNavigate(`/signin`, { email: queryParamModel.email }, true); + hardNavigate(`/signin`, { email }, true); } } } - setShowLoadingSpinner(false); })(); }); @@ -219,12 +218,12 @@ const SignupContainer = ({ navigate('/cannot_create_account'); } - if (showLoadingSpinner) { - return ; - } - - if (validationError) { - hardNavigate('/', {}, true); + if (validationError || !email) { + if (shouldUseReactEmailFirst) { + navigate('/'); + } else { + hardNavigate('/', {}, true); + } return ; } @@ -232,7 +231,7 @@ const SignupContainer = ({ diff --git a/packages/fxa-settings/src/pages/Signup/index.test.tsx b/packages/fxa-settings/src/pages/Signup/index.test.tsx index 6298150360a..b2b741602c6 100644 --- a/packages/fxa-settings/src/pages/Signup/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.test.tsx @@ -18,7 +18,6 @@ import { REACT_ENTRYPOINT } from '../../constants'; import { BEGIN_SIGNUP_HANDLER_FAIL_RESPONSE, BEGIN_SIGNUP_HANDLER_RESPONSE, - MOCK_SEARCH_PARAMS, Subject, createMockSignupOAuthWebIntegration, createMockSignupOAuthNativeIntegration, @@ -50,18 +49,10 @@ jest.mock('../../lib/metrics', () => ({ }), })); -const mockLocation = () => { - return { - pathname: `/signup`, - search: '?' + new URLSearchParams(MOCK_SEARCH_PARAMS), - }; -}; - const mockNavigate = jest.fn(); jest.mock('@reach/router', () => ({ ...jest.requireActual('@reach/router'), useNavigate: () => mockNavigate, - useLocation: () => mockLocation(), })); jest.mock('../../lib/glean', () => ({ @@ -409,9 +400,7 @@ describe('Signup page', () => { }); expect(GleanMetrics.registration.submit).toHaveBeenCalledTimes(1); expect(GleanMetrics.registration.success).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith( - `/cannot_create_account?email=${encodeURIComponent(MOCK_EMAIL)}` - ); + expect(mockNavigate).toHaveBeenCalledWith('/cannot_create_account'); expect(mockBeginSignupHandler).not.toBeCalled(); }); @@ -469,9 +458,7 @@ describe('Signup page', () => { ['a@relay.firefox.com', 'b@mozmail.com', 'c@sub.mozmail.com'].forEach( (mask) => { it(`fails for mask ${mask}`, async () => { - renderWithLocalizationProvider( - - ); + renderWithLocalizationProvider(); await fillOutForm(); submit(); @@ -520,23 +507,20 @@ describe('Signup page', () => { }); // expect navigation to have been called with newsletter slugs - expect(mockNavigate).toHaveBeenCalledWith( - `/confirm_signup_code${mockLocation().search}`, - { - state: { - origin: 'signup', - // we expect three newsletter options, but 4 slugs should be passed - // because the first newsletter checkbox subscribes the user to 2 newsletters - selectedNewsletterSlugs: [ - 'mozilla-and-you', - 'mozilla-accounts', - 'mozilla-foundation', - 'test-pilot', - ], - }, - replace: true, - } - ); + expect(mockNavigate).toHaveBeenCalledWith(`/confirm_signup_code`, { + state: { + origin: 'signup', + // we expect three newsletter options, but 4 slugs should be passed + // because the first newsletter checkbox subscribes the user to 2 newsletters + selectedNewsletterSlugs: [ + 'mozilla-and-you', + 'mozilla-accounts', + 'mozilla-foundation', + 'test-pilot', + ], + }, + replace: true, + }); }); }); @@ -578,16 +562,13 @@ describe('Signup page', () => { expect(fxaLoginSpy).not.toBeCalled(); expect(GleanMetrics.registration.success).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith( - `/confirm_signup_code${mockLocation().search}`, - { - state: { - origin: 'signup', - selectedNewsletterSlugs: [], - }, - replace: true, - } - ); + expect(mockNavigate).toHaveBeenCalledWith(`/confirm_signup_code`, { + state: { + origin: 'signup', + selectedNewsletterSlugs: [], + }, + replace: true, + }); }); describe('Sync integrations', () => { @@ -919,10 +900,7 @@ describe('Signup page', () => { renderWithLocalizationProvider( ); @@ -957,10 +935,7 @@ describe('Signup page', () => { renderWithLocalizationProvider( ); diff --git a/packages/fxa-settings/src/pages/Signup/index.tsx b/packages/fxa-settings/src/pages/Signup/index.tsx index 45a76c6dbd1..1380aadd20f 100644 --- a/packages/fxa-settings/src/pages/Signup/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.tsx @@ -40,12 +40,13 @@ import { SignupFormData, SignupProps } from './interfaces'; import Banner from '../../components/Banner'; import { SensitiveData } from '../../lib/sensitive-data-client'; import { FormSetupAccount } from '../../components/FormSetupAccount'; +import { useCheckReactEmailFirst } from '../../lib/hooks'; export const viewName = 'signup'; export const Signup = ({ integration, - queryParamModel, + email, beginSignupHandler, useSyncEnginesResult: { offeredSyncEngines, @@ -57,6 +58,7 @@ export const Signup = ({ }: SignupProps) => { const sensitiveDataClient = useSensitiveDataClient(); usePageViewEvent(viewName, REACT_ENTRYPOINT); + const shouldUseReactEmailFirst = useCheckReactEmailFirst(); useEffect(() => { GleanMetrics.registration.view(); @@ -66,7 +68,6 @@ export const Signup = ({ const isSyncOAuth = isOAuthNativeIntegrationSync(integration); const isSync = integration.isSync(); const isDesktopRelay = integration.isDesktopRelay(); - const email = queryParamModel.email; const onFocusMetricsEvent = () => { logViewEvent(settingsViewName, `${viewName}.engage`); @@ -268,7 +269,7 @@ export const Signup = ({ origin: 'signup', selectedNewsletterSlugs, // Sync desktop v3 sends a web channel message up on Signup - // while OAuth Sync (mobile) does on confirm signup. + // while OAuth Sync does on confirm signup. // Once mobile clients read this from fxaLogin to match // oauth desktop, we can stop sending this on confirm signup code. ...(isSyncOAuth && { @@ -370,19 +371,28 @@ export const Signup = ({ onClick={async (e) => { e.preventDefault(); GleanMetrics.registration.changeEmail(); - await GleanMetrics.isDone(); // since we navigate away to Backbone - const params = new URLSearchParams(location.search); - // Tell content-server to stay on index and prefill the email - params.set('prefillEmail', email); - // Passing back the 'email' param causes various behaviors in - // content-server since it marks the email as "coming from a RP". - // Also remove `emailStatusChecked` since we pass that when coming - // from content-server to Backbone, see Signup container component - // for more info. - params.delete('emailStatusChecked'); - params.delete('email'); - params.delete('login_hint'); - hardNavigate(`/?${params.toString()}`); + + if (shouldUseReactEmailFirst) { + navigate('/', { + state: { + prefillEmail: email, + }, + }); + } else { + await GleanMetrics.isDone(); // since we navigate away to Backbone + const params = new URLSearchParams(location.search); + // Tell content-server to stay on index and prefill the email + params.set('prefillEmail', email); + // Passing back the 'email' param causes various behaviors in + // content-server since it marks the email as "coming from a RP". + // Also remove `emailStatusChecked` since we pass that when coming + // from content-server to Backbone, see Signup container component + // for more info. + params.delete('emailStatusChecked'); + params.delete('email'); + params.delete('login_hint'); + hardNavigate(`/?${params.toString()}`); + } }} > Change email diff --git a/packages/fxa-settings/src/pages/Signup/interfaces.ts b/packages/fxa-settings/src/pages/Signup/interfaces.ts index 3cc1ccca911..594aa18758f 100644 --- a/packages/fxa-settings/src/pages/Signup/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signup/interfaces.ts @@ -5,7 +5,6 @@ import { HandledError } from '../../lib/error-utils'; import useSyncEngines from '../../lib/hooks/useSyncEngines'; import { BaseIntegration, OAuthIntegration } from '../../models'; -import { SignupQueryParams } from '../../models/pages/signup'; import { MetricsContext } from '@fxa/shared/glean'; export interface BeginSignupResponse { @@ -41,7 +40,7 @@ export interface BeginSignupResult { export interface SignupProps { integration: SignupIntegration; - queryParamModel: SignupQueryParams; + email: string; beginSignupHandler: BeginSignupHandler; useSyncEnginesResult: ReturnType; } diff --git a/packages/fxa-settings/src/pages/Signup/mocks.tsx b/packages/fxa-settings/src/pages/Signup/mocks.tsx index 5ee63202551..50ae70bda40 100644 --- a/packages/fxa-settings/src/pages/Signup/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signup/mocks.tsx @@ -26,10 +26,6 @@ import { } from './interfaces'; import { useMockSyncEngines } from '../../lib/hooks/useSyncEngines/mocks'; -export const MOCK_SEARCH_PARAMS = { - email: MOCK_EMAIL, -}; - export function createMockSignupWebIntegration(): SignupBaseIntegration { return { type: IntegrationType.Web, @@ -129,25 +125,23 @@ export const signupQueryParamsWithContent = { }; export const Subject = ({ - queryParams = signupQueryParams, integration = createMockSignupWebIntegration(), beginSignupHandler = mockBeginSignupHandler, + email = MOCK_EMAIL, }: { - queryParams?: Record; + email?: string; integration?: SignupIntegration; beginSignupHandler?: BeginSignupHandler; }) => { - const urlQueryData = mockUrlQueryData(queryParams); - const queryParamModel = new SignupQueryParams(urlQueryData); const useMockSyncEnginesResult = useMockSyncEngines(); return (