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 ? (
+ <>
+
+
+
+
+
+
+ >
) : (
)}
-
-
-
-
-
-
+ {errorBannerMessage && (
+
+ )}
+ {successBannerMessage && (
+
+ )}
+
+
+
{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 (