packages/fxa-content-server/app/scripts/lib/router.js (781 lines of code) (raw):
/* 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 _ from 'underscore';
import AccountRecoveryConfirmKey from '../views/account_recovery_confirm_key';
import Backbone from 'backbone';
import ChooseWhatToSyncView from '../views/choose_what_to_sync';
import Cocktail from 'cocktail';
import CompleteResetPasswordView from '../views/complete_reset_password';
import CompleteSignUpView from '../views/complete_sign_up';
import ConfirmResetPasswordView from '../views/confirm_reset_password';
import ConfirmView from '../views/confirm';
import ConfirmSignupCodeView from '../views/confirm_signup_code';
import ConnectAnotherDeviceView from '../views/connect_another_device';
import ForceAuthView from '../views/force_auth';
import IndexView from '../views/index';
import InlineTotpSetupView from '../views/inline_totp_setup';
import InlineRecoverySetupView from '../views/inline_recovery_setup';
import PermissionsView from '../views/permissions';
import SupportView from '../views/support';
import ReadyView from '../views/ready';
import RedirectAuthView from '../views/authorization';
import ReportSignInView from '../views/report_sign_in';
import ResetPasswordView from '../views/reset_password';
import SignInBouncedView from '../views/sign_in_bounced';
import SignInPasswordView from '../views/sign_in_password';
import SignInRecoveryCodeView from '../views/sign_in_recovery_code';
import SignInReportedView from '../views/sign_in_reported';
import SignInTokenCodeView from '../views/sign_in_token_code';
import SignInTotpCodeView from '../views/sign_in_totp_code';
import SignInUnblockView from '../views/sign_in_unblock';
import SignUpPasswordView from '../views/sign_up_password';
import ThirdPartyAuthSetPasswordView from '../views/post_verify/third_party_auth/set_password';
import ThirdPartyAuthCallbackView from '../views/post_verify/third_party_auth/callback';
import Storage from './storage';
import SubscriptionsProductRedirectView from '../views/subscriptions_product_redirect';
import SubscriptionsManagementRedirectView from '../views/subscriptions_management_redirect';
import Url from './url';
import UserAgent from './user-agent';
import VerificationReasons from './verification-reasons';
import WouldYouLikeToSync from '../views/would_you_like_to_sync';
import { isAllowed } from 'fxa-shared/configuration/convict-format-allow-list';
import ReactExperimentMixin from './generalized-react-app-experiment-mixin';
import { getClientReactRouteGroups } from '../../../server/lib/routes/react-app/route-groups-client';
const NAVIGATE_AWAY_IN_MOBILE_DELAY_MS = 75;
function getView(ViewOrPath) {
if (typeof ViewOrPath === 'string') {
return import(`../views/${ViewOrPath}`).then((result) => {
if (result.default) {
return result.default;
}
return result;
});
} else {
return Promise.resolve(ViewOrPath);
}
}
function createViewHandler(ViewOrPath, options) {
return function () {
return getView(ViewOrPath).then((View) => {
return this.showView(View, options);
});
};
}
function createChildViewHandler(ChildViewOrPath, ParentViewOrPath, options) {
return function () {
return Promise.all([
getView(ChildViewOrPath),
getView(ParentViewOrPath),
]).then(([ChildView, ParentView]) => {
return this.showChildView(ChildView, ParentView, options);
});
};
}
function createViewModel(data) {
return new Backbone.Model(data || {});
}
let Router = Backbone.Router.extend({
initialize(options = {}) {
this.broker = options.broker;
this.config = options.config;
this.metrics = options.metrics;
this.notifier = options.notifier;
this.relier = options.relier;
this.user = options.user;
this.window = options.window || window;
this._viewModelStack = [];
this.notifier.once(
'view-shown',
this._afterFirstViewHasRendered.bind(this)
);
this.notifier.on('navigate', this.onNavigate.bind(this));
this.notifier.on('navigate-back', this.onNavigateBack.bind(this));
this.notifier.on('email-first-flow', () => this._onEmailFirstFlow());
// If legacy signin/signup flows are disabled, this is obviously
// an email-first flow!
if (this.broker.getCapability('disableLegacySigninSignup')) {
this._isEmailFirstFlow = true;
}
this.storage = Storage.factory('sessionStorage', this.window);
},
});
Cocktail.mixin(Router, ReactExperimentMixin);
Router = Router.extend({
routes: {
'(/)': function () {
this.createReactOrBackboneViewHandler('/', IndexView);
},
'account_recovery_confirm_key(/)': function () {
this.createReactOrBackboneViewHandler(
'account_recovery_confirm_key',
AccountRecoveryConfirmKey
);
},
'account_recovery_reset_password(/)': function () {
this.createReactOrBackboneViewHandler(
'account_recovery_reset_password',
CompleteResetPasswordView
);
},
'authorization(/)': function () {
this.createReactOrBackboneViewHandler('authorization', RedirectAuthView);
},
'cannot_create_account(/)': function () {
this.createReactViewHandler('cannot_create_account');
},
'choose_what_to_sync(/)': createViewHandler(ChooseWhatToSyncView),
'clear(/)': function () {
this.createReactViewHandler('clear');
},
'complete_reset_password(/)': function () {
this.createReactOrBackboneViewHandler(
'complete_reset_password',
CompleteResetPasswordView,
{
...Url.searchParams(this.window.location.search),
}
);
},
// NOTE - complete_signin must be maintained for backwards compatibility with FF <122
// With the react conversion, we should only land on the /complete_signin view
// from signin to sync from version of Firefox <122, when clicking on "resend verification"
// from Sync Settings.
// When Extended Service Release is updated to a version >=122, we could consider an alternate experience,
// such as prompting to update the browser or redirecting to the start of the signin flow so the user can use a code instead.
'complete_signin(/)': function () {
this.createReactOrBackboneViewHandler(
'complete_signin',
CompleteSignUpView,
{
...Url.searchParams(this.window.location.search),
},
{
type: VerificationReasons.SIGN_IN,
}
);
},
// We will not be porting the Confirm view to React, see FXA-9054
'confirm(/)': createViewHandler(ConfirmView, {
type: VerificationReasons.SIGN_UP,
}),
'confirm_reset_password(/)': function () {
this.createReactOrBackboneViewHandler(
'confirm_reset_password',
ConfirmResetPasswordView
);
},
// We will not be porting the Confirm view to React, see FXA-9054
'confirm_signin(/)': createViewHandler(ConfirmView, {
type: VerificationReasons.SIGN_IN,
}),
'confirm_signup_code(/)': function () {
/* If a user initiates the OAuth Signup flow in React (e.g. they create an account
* through an RP), they will be navigated to the React version of `confirm_signup_code`
* and can be redirected to that RP after signup completion as expected.
*
* *However*, `keyFetchToken` and `unwrapBKey`, which are used on `confirm_signup_code`
* in the OAuth flow, are provided to us when a user creates an unverified account.
* We do not want to pass these params back and forth between Backbone and React.
* We can also retrieve these when a user signs in.
* This means users that have previously created an account but did not verify it
* and are in the OAuth flow will be in a problematic state when going from Backbone's
* `signin` to React's `confirm_signup_code`. For this case, we want to use the
* Backbone `confirm_signup_code` until `signin` is Reactified. See:
* https://github.com/mozilla/fxa/pull/15839/files#r1344333026
*
* Later comment: additionally, we need `keyFetchToken` and `unwrapBKey` to send a
* webchannel message to the browser for Sync. For this case, we will also show
* Backbone's `confirm_signup_code` until `signin` is Reactified.
* */
const routeName = 'confirm_signup_code';
// Users that have already reached React Signup will be navigated in-app to this
// page next (in React). This check handles the OAuth flow and Sync flow when the
// previous page was Backbone `/signin` - always show Backbone `confirm_signup_code`.
if (this.relier.isOAuth() || this.relier.isSync()) {
return getView(routeName).then((View) => {
return this.showView(View);
});
} else {
this.createReactOrBackboneViewHandler(
routeName,
ConfirmSignupCodeView,
{
...Url.searchParams(this.window.location.search),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
}
);
}
},
'connect_another_device(/)': createViewHandler(ConnectAnotherDeviceView),
'cookies_disabled(/)': function () {
this.createReactViewHandler('cookies_disabled', {
// HACK: this page uses the history API to navigate back and must go back one page
// further if being redirected from content-server. Flow params are not always
// available to check against, so we explicitly send in an additional param.
contentRedirect: true,
// We pass along `disable_local_storage` for functional-tests that hit another page
// with this param (synthetically disabling local storage). Without this, tests will
// be redirected to this page, but local storage will appear enabled.
/* eslint-disable camelcase */
...(Url.searchParam(
'disable_local_storage',
this.window.location.search
) === '1' && {
disable_local_storage: 1,
}),
});
},
'force_auth(/)': function () {
this.createReactOrBackboneViewHandler('force_auth', ForceAuthView, {
...Url.searchParams(this.window.location.search),
email: this.user.get('emailFromIndex'),
hasLinkedAccount: this.user.get('hasLinkedAccount'),
hasPassword: this.user.get('hasPassword'),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
});
},
'inline_totp_setup(/)': function () {
this.createReactOrBackboneViewHandler(
'inline_totp_setup',
InlineTotpSetupView
);
},
'inline_recovery_setup(/)': function () {
this.createReactOrBackboneViewHandler(
'inline_recovery_setup',
InlineRecoverySetupView
);
},
'legal(/)': function () {
this.createReactViewHandler('legal');
},
'legal/privacy(/)': function () {
this.createReactViewHandler('legal/privacy', 'pp', {
contentRedirect: true,
});
},
':lang/legal/privacy(/)': function () {
this.createReactViewHandler('legal/privacy', 'pp', {
contentRedirect: true,
});
},
'legal/terms(/)': function () {
this.createReactViewHandler('legal/terms', 'tos', {
contentRedirect: true,
});
},
':lang/legal/terms(/)': function () {
this.createReactViewHandler('legal/terms', 'tos', {
contentRedirect: true,
});
},
'oauth(/)': function () {
this.createReactOrBackboneViewHandler('oauth', IndexView);
},
'oauth/force_auth(/)': function () {
this.createReactOrBackboneViewHandler('oauth/force_auth', ForceAuthView, {
...Url.searchParams(this.window.location.search),
email: this.user.get('emailFromIndex'),
hasLinkedAccount: this.user.get('hasLinkedAccount'),
hasPassword: this.user.get('hasPassword'),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
});
},
'oauth/signin(/)': function () {
this.createReactOrBackboneViewHandler(
'oauth/signin',
SignInPasswordView,
{
// see comment in fxa-settings/src/pages/Signin/container.tsx for param explanation
...Url.searchParams(this.window.location.search),
email: this.user.get('emailFromIndex'),
hasLinkedAccount: this.user.get('hasLinkedAccount'),
hasPassword: this.user.get('hasPassword'),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
}
);
},
'oauth/signup(/)': function () {
this.createReactOrBackboneViewHandler(
'oauth/signup',
SignUpPasswordView,
{
// see comment in fxa-settings/src/pages/Signup/container.tsx for param explanation
email: this.user.get('emailFromIndex'),
...(this.user.get('emailFromIndex') && {
emailStatusChecked: 'true',
}),
...Url.searchParams(this.window.location.search),
}
);
},
'oauth/success/:client_id(/)': createViewHandler(ReadyView, {
type: VerificationReasons.SUCCESSFUL_OAUTH,
}),
'pair(/)': createViewHandler('pair/index'),
'pair/auth/allow(/)': createViewHandler('pair/auth_allow'),
'pair/auth/complete(/)': createViewHandler('pair/auth_complete'),
'pair/auth/totp(/)': createViewHandler('pair/auth_totp'),
'pair/auth/wait_for_supp(/)': createViewHandler('pair/auth_wait_for_supp'),
'pair/failure(/)': createViewHandler('pair/failure'),
'pair/success(/)': createViewHandler('pair/success'),
'pair/supp(/)': createViewHandler('pair/supp', { force: true }),
'pair/supp/allow(/)': createViewHandler('pair/supp_allow'),
'pair/supp/wait_for_auth(/)': createViewHandler('pair/supp_wait_for_auth'),
'pair/unsupported(/)': createViewHandler('pair/unsupported'),
'post_verify/finish_account_setup/set_password': createViewHandler(
'post_verify/finish_account_setup/set_password'
),
'post_verify/cad_qr/get_started': createViewHandler(
'post_verify/cad_qr/get_started'
),
'post_verify/cad_qr/ready_to_scan': createViewHandler(
'post_verify/cad_qr/ready_to_scan'
),
'post_verify/cad_qr/scan_code': createViewHandler(
'post_verify/cad_qr/scan_code'
),
'post_verify/cad_qr/connected': createViewHandler(
'post_verify/cad_qr/connected'
),
'post_verify/newsletters/add_newsletters': createViewHandler(
'post_verify/newsletters/add_newsletters'
),
'post_verify/password/force_password_change': createViewHandler(
'post_verify/password/force_password_change'
),
'post_verify/secondary_email/add_secondary_email': createViewHandler(
'post_verify/secondary_email/add_secondary_email'
),
'post_verify/secondary_email/confirm_secondary_email': createViewHandler(
'post_verify/secondary_email/confirm_secondary_email'
),
'post_verify/secondary_email/verified_secondary_email': createViewHandler(
'post_verify/verified',
{
type: VerificationReasons.SECONDARY_EMAIL_VERIFIED,
}
),
'post_verify/third_party_auth/callback(/)': function () {
this.createReactOrBackboneViewHandler(
'post_verify/third_party_auth/callback',
ThirdPartyAuthCallbackView
);
},
'post_verify/third_party_auth/set_password(/)': function () {
this.createReactOrBackboneViewHandler(
'post_verify/third_party_auth/set_password',
ThirdPartyAuthSetPasswordView
);
},
'push/confirm_login(/)': createViewHandler('push/confirm_login'),
'push/send_login(/)': createViewHandler('push/send_login'),
'push/completed(/)': createViewHandler('push/completed'),
'primary_email_verified(/)': function () {
this.createReactOrBackboneViewHandler(
'primary_email_verified',
ReadyView,
null,
{
type: VerificationReasons.PRIMARY_EMAIL_VERIFIED,
}
);
},
'report_signin(/)': function () {
this.createReactOrBackboneViewHandler('report_signin', ReportSignInView, {
...Url.searchParams(this.window.location.search),
});
},
'reset_password(/)': function () {
this.createReactOrBackboneViewHandler(
'reset_password',
ResetPasswordView,
{
...Url.searchParams(this.window.location.search),
}
);
},
'reset_password_confirmed(/)': function () {
this.createReactOrBackboneViewHandler(
'reset_password_verified',
ReadyView,
null,
{
type: VerificationReasons.PASSWORD_RESET,
}
);
},
'reset_password_with_recovery_key_verified(/)': function () {
this.createReactOrBackboneViewHandler(
'reset_password_with_recovery_key_verified',
ReadyView,
null,
{
type: VerificationReasons.PASSWORD_RESET_WITH_RECOVERY_KEY,
}
);
},
'reset_password_verified(/)': function () {
this.createReactOrBackboneViewHandler(
'reset_password_verified',
ReadyView,
null,
{
type: VerificationReasons.PASSWORD_RESET,
}
);
},
'secondary_email_verified(/)': createViewHandler(ReadyView, {
type: VerificationReasons.SECONDARY_EMAIL_VERIFIED,
}),
'settings(/)': function () {
// Because settings is a separate js app, we need to ensure navigating
// from the content-server app passes along flow parameters.
const { deviceId, flowBeginTime, flowId } =
this.metrics.getFlowEventMetadata();
const {
broker,
context: ctx,
isSampledUser,
service,
uniqueUserId,
} = this.metrics.getFilteredData();
// Some flows can specify a redirect url after a client has logged in. This is
// useful when you want to ensure the user has authenticated. Our GQL client
// also sets the `redirect_to` param if a user attempts to navigate directly
// to a section in settings
const searchParams = new URLSearchParams(this.window.location.search);
const redirectUrl = searchParams.get('redirect_to');
if (redirectUrl) {
if (!this.isValidRedirect(redirectUrl, this.config.redirectAllowlist)) {
throw new Error('Invalid redirect!');
}
return this.navigateAway(redirectUrl);
}
// All other flows should redirect to the settings page
const settingsEndpoint = '/settings';
const settingsLink = `${settingsEndpoint}${Url.objToSearchString({
deviceId,
flowBeginTime,
flowId,
broker,
context: ctx,
isSampledUser,
service,
uniqueUserId,
})}`;
this.navigateAway(settingsLink);
},
'signin(/)': function () {
this.createReactOrBackboneViewHandler('signin', SignInPasswordView, {
// see comment in fxa-settings/src/pages/Signin/container.tsx for param explanation
...Url.searchParams(this.window.location.search),
email: this.user.get('emailFromIndex'),
hasLinkedAccount: this.user.get('hasLinkedAccount'),
hasPassword: this.user.get('hasPassword'),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
});
},
'signin_bounced(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_bounced',
SignInBouncedView
);
},
'signin_confirmed(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_confirmed',
ReadyView,
null,
{
type: VerificationReasons.SIGN_IN,
}
);
},
'signin_permissions(/)': createViewHandler(PermissionsView, {
type: VerificationReasons.SIGN_IN,
}),
'signin_recovery_choice(/)': function () {
this.createReactViewHandler('signin_recovery_choice');
},
'signin_recovery_code(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_recovery_code',
SignInRecoveryCodeView
);
},
'signin_recovery_phone(/)': function () {
this.createReactViewHandler('signin_recovery_phone');
},
'signin_reported(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_reported',
SignInReportedView
);
},
'signin_token_code(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_token_code',
SignInTokenCodeView,
{
...Url.searchParams(this.window.location.search),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
},
{
type: VerificationReasons.SIGN_IN,
}
);
},
'signin_totp_code(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_totp_code',
SignInTotpCodeView,
{
...Url.searchParams(this.window.location.search),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
}
);
},
'signin_push_code(/)': function () {
this.createReactViewHandler('signin_push_code', {
...Url.searchParams(this.window.location.search),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
});
},
'signin_push_code_confirm(/)': function () {
this.createReactViewHandler('signin_push_code_confirm', {
...Url.searchParams(this.window.location.search),
});
},
'signin_unblock(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_unblock',
SignInUnblockView,
{
...Url.searchParams(this.window.location.search),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
}
);
},
'signin_verified(/)': function () {
this.createReactOrBackboneViewHandler(
'signin_verified',
ReadyView,
null,
{
type: VerificationReasons.SIGN_IN,
}
);
},
'signup(/)': function () {
this.createReactOrBackboneViewHandler('signup', SignUpPasswordView, {
...Url.searchParams(this.window.location.search),
// see comment in fxa-settings/src/pages/Signup/container.tsx for param explanation
email: this.user.get('emailFromIndex'),
...(this.user.get('emailFromIndex') && {
emailStatusChecked: 'true',
}),
// for subplat redirect only
...(this.relier.get('redirectTo') && {
redirect_to: this.relier.get('redirectTo'),
}),
});
},
'signup_confirmed(/)': function () {
this.createReactOrBackboneViewHandler(
'signup_confirmed',
ReadyView,
null,
{
type: VerificationReasons.SIGN_UP,
}
);
},
'signup_permissions(/)': createViewHandler(PermissionsView, {
type: VerificationReasons.SIGN_UP,
}),
'signup_verified(/)': function () {
this.createReactOrBackboneViewHandler(
'signup_verified',
ReadyView,
null,
{
type: VerificationReasons.SIGN_UP,
}
);
},
'subscriptions/products/:productId': createViewHandler(
SubscriptionsProductRedirectView
),
'subscriptions(/)': createViewHandler(SubscriptionsManagementRedirectView),
'support(/)': createViewHandler(SupportView),
'verify_email(/)': createViewHandler(CompleteSignUpView, {
type: VerificationReasons.SIGN_UP,
}),
'verify_primary_email(/)': createViewHandler(CompleteSignUpView, {
type: VerificationReasons.PRIMARY_EMAIL_VERIFIED,
}),
'verify_secondary_email(/)': createViewHandler(CompleteSignUpView, {
type: VerificationReasons.SECONDARY_EMAIL_VERIFIED,
}),
'would_you_like_to_sync(/)': createViewHandler(WouldYouLikeToSync),
},
/**
* Checks that 1) the feature flag is on, 2) the route is included in
* react-app/index.js, and 3) that the user is in the React experiment.
* @param routeName string
* @returns boolean
* */
showReactApp(routeName) {
const reactRouteGroups = this.getReactRouteGroups();
for (const routeGroup in reactRouteGroups) {
if (
reactRouteGroups[routeGroup].routes.find((route) => routeName === route)
) {
return (
reactRouteGroups[routeGroup].featureFlagOn &&
(this.isInReactExperiment() ||
reactRouteGroups[routeGroup].fullProdRollout === true)
);
}
}
return false;
},
getReactRouteGroups() {
return getClientReactRouteGroups(this.config.showReactApp);
},
createReactViewHandler(routeName, additionalParams) {
if (routeName === '/') {
// We intentionally avoid using URLSearchParams because it converts '+'
// characters to spaces per the application/x-www-form-urlencoded
// standard. This can corrupt email addresses (e.g., "user+alias@example.com").
// Instead, we use our custom objToSearchString function, which leverages
// encodeURIComponent to correctly preserve and encode all valid email characters.
const rawSearch = window.location.search.substring(1);
const paramsObject = Url.searchParams(rawSearch);
paramsObject.showReactApp = 'true';
const newSearchString = Url.objToSearchString(paramsObject);
this.navigateAway(`/${newSearchString}`);
} else {
const { deviceId, flowBeginTime, flowId } =
this.metrics.getFlowEventMetadata();
const { uniqueUserId } = this.metrics.getFilteredData();
const link = `/${routeName}${Url.objToSearchString({
showReactApp: true,
deviceId,
flowBeginTime,
flowId,
uniqueUserId,
...additionalParams,
})}`;
this.navigateAway(link);
}
},
createReactOrBackboneViewHandler(
routeName,
ViewOrPath, // for backbone
additionalParams, // for react
backboneViewOptions
) {
const showReactApp = this.showReactApp(routeName);
if (showReactApp) {
this.createReactViewHandler(routeName, additionalParams);
} else {
return getView(ViewOrPath).then((View) => {
return this.showView(View, backboneViewOptions);
});
}
},
onNavigate(event) {
if (event.server) {
return this.navigateAway(event.url);
}
this.navigate(event.url, event.nextViewData, event.routerOptions);
},
onNavigateBack(event) {
this.navigateBack(event.nextViewData);
},
/**
* Navigate to `url` using `nextViewData` as the data for the view's model.
*
* @param {String} url
* @param {Object} [nextViewData={}]
* @param {Object} [options={}]
* @param {Boolean} [options.clearQueryParams=false] Clear the query parameters?
* @param {Boolean} [options.replace=false] Replace the current view?
* @param {Boolean} [options.trigger=true] Show the new view?
* @returns {any}
*/
navigate(url, nextViewData = {}, options = {}) {
url = this.broker.transformLink(url);
if (options.replace && this._viewModelStack.length) {
this._viewModelStack[this._viewModelStack.length - 1] =
createViewModel(nextViewData);
} else {
this._viewModelStack.push(createViewModel(nextViewData));
}
// eslint-disable-next-line no-prototype-builtins
if (!options.hasOwnProperty('trigger')) {
options.trigger = true;
}
// If the URL to navigate to has the origin as a prefix,
// remove the origin and just use from the path on. This
// prevents a situation where for url=http://accounts.firefox.com/settings,
// backbone sending the user to http://accounts.firefox.com/http://accounts.firefox.com/settings
if (url.indexOf(this.window.location.origin) === 0) {
url = url.replace(this.window.location.origin, '');
}
const shouldClearQueryParams = !!options.clearQueryParams;
const hasQueryParams = /\?/.test(url);
// If the caller has not asked us to clear the query params
// and the new URL does not contain query params, propagate
// the current query params to the next view.
if (!shouldClearQueryParams && !hasQueryParams) {
url = url + this.window.location.search;
} else if (shouldClearQueryParams && hasQueryParams) {
url = url.split('?')[0];
}
// With the conversion to react, we needed to pass bounced email address as a param
// when redirecting to backbone email-first sign in/sign up
// however, we don't want this param to follow like a bad smell
// when the user enters a valid email and successfully navigates away
const params = new URLSearchParams(url.split('?')[1]);
if (params.get('bouncedEmail')) {
const path = url.split('?')[0];
params.delete('bouncedEmail');
if (params.size) {
url = path + '?' + params.toString();
} else {
url = path;
}
}
if (this.window.location.hash) {
url += this.window.location.hash;
}
return Backbone.Router.prototype.navigate.call(this, url, options);
},
/**
* Checks to see if a url contains a host that we can redirect to. Supports relative paths as well
* absolute urls.
* @param {string} redirectLocation - location to redirect to
* @param {string[]} allowlist - list of allowed hosts
* @param {RegExp[]} A list of regular expressions to validate against
*/
isValidRedirect(redirectLocation, allowlist) {
return isAllowed(redirectLocation, window.location, allowlist);
},
/**
* Navigate externally to the application, flushing the metrics
* before doing so.
*
* @param {String} url
* @returns {Promise}
*/
async navigateAway(url) {
// issue #5626: external links should not get transformed
if (!/^https?:/.test(url)) {
url = this.broker.transformLink(url);
}
await this.metrics.flush();
// issue https://github.com/mozilla/fxa/issues/11917:
// For mobile devices, we add a small delay before redirecting so that our metric
// events get flushed properly. This is a workaround since they're
// getting blocked on mobile devices when the flushing occurs too quickly.
const userAgent = UserAgent(this.window.navigator.userAgent);
if (userAgent && userAgent.device && userAgent.device.type === 'mobile') {
return new Promise((resolve) => {
setTimeout(() => {
this.window.location.href = url;
resolve();
}, NAVIGATE_AWAY_IN_MOBILE_DELAY_MS);
});
}
this.window.location.href = url;
},
/**
* Go back one URL, combining the previous view's viewModel
* with the data in `previousViewData`.
*
* @param {Object} [previousViewData={}]
*/
navigateBack(previousViewData = {}) {
if (this.canGoBack()) {
// ditch the current view's model, go back to the previous view's model.
this._viewModelStack.pop();
const viewModel = this.getCurrentViewModel();
if (viewModel) {
viewModel.set(previousViewData);
}
this.window.history.back();
}
},
/**
* Get the current viewModel, if one is available.
*
* @returns {Object}
*/
getCurrentViewModel() {
if (this._viewModelStack.length) {
return this._viewModelStack[this._viewModelStack.length - 1];
}
},
/**
* Get the options to pass to a View constructor.
*
* @param {Object} options - additional options
* @returns {Object}
*/
getViewOptions(options) {
// passed in options block can override
// default options.
return _.extend(
{
canGoBack: this.canGoBack(),
currentPage: this.getCurrentPage(),
model: this.getCurrentViewModel(),
viewName: this.getCurrentViewName(),
},
options
);
},
/**
* Is it possible to go back?
*
* @returns {Boolean}
*/
canGoBack() {
return !!this.storage.get('canGoBack');
},
/**
* Get the pathname of the current page.
*
* @returns {String}
*/
getCurrentPage() {
const fragment = Backbone.history.fragment || '';
// strip leading /
return (
fragment
.replace(/^\//, '')
// strip trailing /
.replace(/\/$/, '')
// we only want the pathname
.replace(/\?.*/, '')
);
},
getCurrentViewName() {
return this.fragmentToViewName(this.getCurrentPage());
},
_afterFirstViewHasRendered() {
// back is enabled after the first view is rendered or
// if the user re-starts the app.
this.storage.set('canGoBack', true);
},
_onEmailFirstFlow() {
this._isEmailFirstFlow = true;
// back is enabled for email-first so that
// users can go back to the / screen from "Mistyped email".
// The initial navigation to the next screen
// happens before the / page is rendered, causing
// `canGoBack` to not be set.
this.storage.set('canGoBack', true);
},
fragmentToViewName(fragment) {
fragment = fragment || '';
// strip leading /
return (
fragment
.replace(/^\//, '')
// strip trailing /
.replace(/\/$/, '')
// any other slashes get converted to '.'
.replace(/\//g, '.')
// search params can contain sensitive info
.replace(/\?.*/, '')
// replace _ with -
.replace(/_/g, '-')
);
},
/**
* Notify the system a new View should be shown.
*
* @param {Function} View - view constructor
* @param {Object} [options]
*/
showView(View, options) {
this.notifier.trigger('show-view', View, this.getViewOptions(options));
},
/**
* Notify the system a new ChildView should be shown.
*
* @param {Function} ChildView - view constructor
* @param {Function} ParentView - view constructor,
* the parent of the ChildView
* @param {Object} [options]
*/
showChildView(ChildView, ParentView, options) {
this.notifier.trigger(
'show-child-view',
ChildView,
ParentView,
this.getViewOptions(options)
);
},
/**
* Create a route handler that is used to display a View
*
* @param {Function} View - constructor of view to show
* @param {Object} [options] - options to pass to View constructor
* @returns {Function} - a function that can be given to the router.
*/
createViewHandler: createViewHandler,
/**
* Create a route handler that is used to display a ChildView inside of
* a ParentView. Views will be created as needed.
*
* @param {Function} ChildView - constructor of ChildView to show
* @param {Function} ParentView - constructor of ParentView to show
* @param {Object} [options] - options to pass to ChildView &
* ParentView constructors
* @returns {Function} - a function that can be given to the router.
*/
createChildViewHandler: createChildViewHandler,
});
export default Router;