Skip to content

Commit

Permalink
feat(react): Reactify email-first/Index page
Browse files Browse the repository at this point in the history
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
  • Loading branch information
LZoog committed Mar 10, 2025
1 parent df26816 commit 41e225d
Show file tree
Hide file tree
Showing 63 changed files with 1,396 additions and 495 deletions.
32 changes: 19 additions & 13 deletions packages/fxa-content-server/app/scripts/lib/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions packages/fxa-content-server/server/bin/fxa-content-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion packages/fxa-content-server/server/config/local.json-dist
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"resetPasswordRoutes": true,
"signUpRoutes": true,
"signInRoutes": true,
"emailFirstRoutes": false,
"emailFirstRoutes": true,
"postVerifyThirdPartyAuthRoutes": true
},
"featureFlags": {
Expand Down
1 change: 1 addition & 0 deletions packages/fxa-content-server/server/lib/beta-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
5 changes: 3 additions & 2 deletions packages/fxa-content-server/server/lib/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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),
Expand Down
202 changes: 18 additions & 184 deletions packages/fxa-content-server/server/lib/routes/get-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand All @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const getReactRouteGroups = (showReactApp, reactRoute) => {
return {
emailFirstRoutes: {
featureFlagOn: showReactApp.emailFirstRoutes,
routes: reactRoute.getRoutes([]),
routes: reactRoute.getRoutes(['/']),
fullProdRollout: false,
},
simpleRoutes: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
) {
Expand Down
Loading

0 comments on commit 41e225d

Please sign in to comment.