summaryrefslogtreecommitdiffstats
path: root/mobile/android/chrome/content/aboutAccounts.js
blob: 4801a76a1e74cbec4ca0440a12db50308e981b36 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* 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/. */

/**
 * Wrap a remote fxa-content-server.
 *
 * An about:accounts tab loads and displays an fxa-content-server page,
 * depending on the current Android Account status and an optional 'action'
 * parameter.
 *
 * We show a spinner while the remote iframe is loading.  We expect the
 * WebChannel message listening to the fxa-content-server to send this tab's
 * <browser>'s messageManager a LOADED message when the remote iframe provides
 * the WebChannel LOADED message.  See the messageManager registration and the
 * |loadedDeferred| promise.  This loosely couples the WebChannel implementation
 * and about:accounts!  (We need this coupling in order to distinguish
 * WebChannel LOADED messages produced by multiple about:accounts tabs.)
 *
 * We capture error conditions by accessing the inner nsIWebNavigation of the
 * iframe directly.
 */

"use strict";

var {classes: Cc, interfaces: Ci, utils: Cu} = Components; /*global Components */

Cu.import("resource://gre/modules/Accounts.jsm"); /*global Accounts */
Cu.import("resource://gre/modules/PromiseUtils.jsm"); /*global PromiseUtils */
Cu.import("resource://gre/modules/Services.jsm"); /*global Services */
Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /*global XPCOMUtils */

const ACTION_URL_PARAM = "action";

const COMMAND_LOADED = "fxaccounts:loaded";

const log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("FxAccounts");

XPCOMUtils.defineLazyServiceGetter(this, "ParentalControls",
  "@mozilla.org/parental-controls-service;1", "nsIParentalControlsService");

// Shows the toplevel element with |id| to be shown - all other top-level
// elements are hidden.
// If |id| is 'spinner', then 'remote' is also shown, with opacity 0.
function show(id) {
  let allTop = document.querySelectorAll(".toplevel");
  for (let elt of allTop) {
    if (elt.getAttribute("id") == id) {
      elt.style.display = 'block';
    } else {
      elt.style.display = 'none';
    }
  }
  if (id == 'spinner') {
    document.getElementById('remote').style.display = 'block';
    document.getElementById('remote').style.opacity = 0;
  }
}

// Each time we try to load the remote <iframe>, loadedDeferred is replaced.  It
// is resolved by a LOADED message, and rejected by a failure to load.
var loadedDeferred = null;

// We have a new load starting.  Replace the existing promise with a new one,
// and queue up the transition to remote content.
function deferTransitionToRemoteAfterLoaded() {
  log.d('Waiting for LOADED message.');

  loadedDeferred = PromiseUtils.defer();
  loadedDeferred.promise.then(() => {
    log.d('Got LOADED message!');
    document.getElementById("remote").style.opacity = 0;
    show("remote");
    document.getElementById("remote").style.opacity = 1;
  })
  .catch((e) => {
    log.w('Did not get LOADED message: ' + e.toString());
  });
}

function handleLoadedMessage(message) {
  loadedDeferred.resolve();
};

var wrapper = {
  iframe: null,

  url: null,

  init: function (url) {
    this.url = url;
    deferTransitionToRemoteAfterLoaded();

    let iframe = document.getElementById("remote");
    this.iframe = iframe;
    this.iframe.QueryInterface(Ci.nsIFrameLoaderOwner);
    let docShell = this.iframe.frameLoader.docShell;
    docShell.QueryInterface(Ci.nsIWebProgress);
    docShell.addProgressListener(this.iframeListener, Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT);

    // Set the iframe's location with loadURI/LOAD_FLAGS_BYPASS_HISTORY to
    // avoid having a new history entry being added.
    let webNav = iframe.frameLoader.docShell.QueryInterface(Ci.nsIWebNavigation);
    webNav.loadURI(url, Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, null, null, null);
  },

  retry: function () {
    deferTransitionToRemoteAfterLoaded();

    let webNav = this.iframe.frameLoader.docShell.QueryInterface(Ci.nsIWebNavigation);
    webNav.loadURI(this.url, Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, null, null, null);
  },

  iframeListener: {
    QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener,
                                         Ci.nsISupportsWeakReference,
                                         Ci.nsISupports]),

    onStateChange: function(aWebProgress, aRequest, aState, aStatus) {
      let failure = false;

      // Captive portals sometimes redirect users
      if ((aState & Ci.nsIWebProgressListener.STATE_REDIRECTING)) {
        failure = true;
      } else if ((aState & Ci.nsIWebProgressListener.STATE_STOP)) {
        if (aRequest instanceof Ci.nsIHttpChannel) {
          try {
            failure = aRequest.responseStatus != 200;
          } catch (e) {
            failure = aStatus != Components.results.NS_OK;
          }
        }
      }

      // Calling cancel() will raise some OnStateChange notifications by itself,
      // so avoid doing that more than once
      if (failure && aStatus != Components.results.NS_BINDING_ABORTED) {
        aRequest.cancel(Components.results.NS_BINDING_ABORTED);
        // Since after a promise is fulfilled, subsequent fulfillments are
        // treated as no-ops, we don't care that we might see multiple failures
        // due to multiple listener callbacks.  (It's not easy to extract this
        // from the Promises spec, but it is widely quoted.  Start with
        // http://stackoverflow.com/a/18218542.)
        loadedDeferred.reject(new Error("Failed in onStateChange!"));
        show("networkError");
      }
    },

    onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) {
      if (aRequest && aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
        aRequest.cancel(Components.results.NS_BINDING_ABORTED);
        // As above, we're not concerned by multiple listener callbacks.
        loadedDeferred.reject(new Error("Failed in onLocationChange!"));
        show("networkError");
      }
    },

    onProgressChange: function() {},
    onStatusChange: function() {},
    onSecurityChange: function() {},
  },
};


function retry() {
  log.i("Retrying.");
  show("spinner");
  wrapper.retry();
}

function openPrefs() {
  log.i("Opening Sync preferences.");
  // If an Android Account exists, this will open the Status Activity.
  // Otherwise, it will begin the Get Started flow.  This should only be shown
  // when an Account actually exists.
  Accounts.launchSetup();
}

function getURLForAction(action, urlParams) {
  let url = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.webchannel.uri");
  url = url + (url.endsWith("/") ? "" : "/") + action;
  const CONTEXT = "fx_fennec_v1";
  // The only service managed by Fennec, to date, is Firefox Sync.
  const SERVICE = "sync";
  urlParams = urlParams || new URLSearchParams("");
  urlParams.set('service', SERVICE);
  urlParams.set('context', CONTEXT);
  // Ideally we'd just merge urlParams with new URL(url).searchParams, but our
  // URLSearchParams implementation doesn't support iteration (bug 1085284).
  let urlParamStr = urlParams.toString();
  if (urlParamStr) {
    url += (url.includes("?") ? "&" : "?") + urlParamStr;
  }
  return url;
}

function updateDisplayedEmail(user) {
  let emailDiv = document.getElementById("email");
  if (emailDiv && user) {
    emailDiv.textContent = user.email;
  }
}

function init() {
  // Test for restrictions before getFirefoxAccount(), since that will fail if
  // we are restricted.
  if (!ParentalControls.isAllowed(ParentalControls.MODIFY_ACCOUNTS)) {
    // It's better to log and show an error message than to invite user
    // confusion by removing about:accounts entirely.  That is, if the user is
    // restricted, this way they'll discover as much and may be able to get
    // out of their restricted profile.  If we remove about:accounts entirely,
    // it will look like Fennec is buggy, and the user will be very confused.
    log.e("This profile cannot connect to Firefox Accounts: showing restricted error.");
    show("restrictedError");
    return;
  }

  Accounts.getFirefoxAccount().then(user => {
    // It's possible for the window to start closing before getting the user
    // completes.  Tests in particular can cause this.
    if (window.closed) {
      return;
    }

    updateDisplayedEmail(user);

    // Ideally we'd use new URL(document.URL).searchParams, but for about: URIs,
    // searchParams is empty.
    let urlParams = new URLSearchParams(document.URL.split("?")[1] || "");
    let action = urlParams.get(ACTION_URL_PARAM);
    urlParams.delete(ACTION_URL_PARAM);

    switch (action) {
    case "signup":
      if (user) {
        // Asking to sign-up when already signed in just shows prefs.
        show("prefs");
      } else {
        show("spinner");
        wrapper.init(getURLForAction("signup", urlParams));
      }
      break;
    case "signin":
      if (user) {
        // Asking to sign-in when already signed in just shows prefs.
        show("prefs");
      } else {
        show("spinner");
        wrapper.init(getURLForAction("signin", urlParams));
      }
      break;
    case "force_auth":
      if (user) {
        show("spinner");
        urlParams.set("email", user.email); // In future, pin using the UID.
        wrapper.init(getURLForAction("force_auth", urlParams));
      } else {
        show("spinner");
        wrapper.init(getURLForAction("signup", urlParams));
      }
      break;
    case "manage":
      if (user) {
        show("spinner");
        urlParams.set("email", user.email); // In future, pin using the UID.
        wrapper.init(getURLForAction("settings", urlParams));
      } else {
        show("spinner");
        wrapper.init(getURLForAction("signup", urlParams));
      }
      break;
    case "avatar":
      if (user) {
        show("spinner");
        urlParams.set("email", user.email); // In future, pin using the UID.
        wrapper.init(getURLForAction("settings/avatar/change", urlParams));
      } else {
        show("spinner");
        wrapper.init(getURLForAction("signup", urlParams));
      }
      break;
    default:
      // Unrecognized or no action specified.
      if (action) {
        log.w("Ignoring unrecognized action: " + action);
      }
      if (user) {
        show("prefs");
      } else {
        show("spinner");
        wrapper.init(getURLForAction("signup", urlParams));
      }
      break;
    }
  }).catch(e => {
    log.e("Failed to get the signed in user: " + e.toString());
  });
}

document.addEventListener("DOMContentLoaded", function onload() {
  document.removeEventListener("DOMContentLoaded", onload, true);
  init();
  var buttonRetry = document.getElementById('buttonRetry');
  buttonRetry.addEventListener('click', retry);

  var buttonOpenPrefs = document.getElementById('buttonOpenPrefs');
  buttonOpenPrefs.addEventListener('click', openPrefs);
}, true);

// This window is contained in a XUL <browser> element.  Return the
// messageManager of that <browser> element, or null.
function getBrowserMessageManager() {
  let browser = window
        .QueryInterface(Ci.nsIInterfaceRequestor)
        .getInterface(Ci.nsIWebNavigation)
        .QueryInterface(Ci.nsIDocShellTreeItem)
        .rootTreeItem
        .QueryInterface(Ci.nsIInterfaceRequestor)
        .getInterface(Ci.nsIDOMWindow)
        .QueryInterface(Ci.nsIDOMChromeWindow)
        .BrowserApp
        .getBrowserForDocument(document);
  if (browser) {
    return browser.messageManager;
  }
  return null;
}

// Add a single listener for 'loaded' messages from the iframe in this
// <browser>.  These 'loaded' messages are ferried from the WebChannel to just
// this <browser>.
var mm = getBrowserMessageManager();
if (mm) {
  mm.addMessageListener(COMMAND_LOADED, handleLoadedMessage);
} else {
  log.e('No messageManager, not listening for LOADED message!');
}

window.addEventListener("unload", function(event) {
  try {
    let mm = getBrowserMessageManager();
    if (mm) {
      mm.removeMessageListener(COMMAND_LOADED, handleLoadedMessage);
    }
  } catch (e) {
    // This could fail if the page is being torn down, the tab is being
    // destroyed, etc.
    log.w('Not removing listener for LOADED message: ' + e.toString());
  }
});