Refresh expired credentials

Show error toast with a link to sign-in child window. Close child window
on successful login and refresh credentials without reloading page.
Listen to window focus (using Page Visibility API) to refresh
credentials immediately after window/tab focus.

Feature: Issue 4097
Change-Id: If5c798b6ac3b4cb97f2ac537c9f5b4b58cf58f9a
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 757d79f..f2b0d78 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -14,16 +14,17 @@
 (function() {
   'use strict';
 
+  var HIDE_ALERT_TIMEOUT_MS = 5000;
+  var CHECK_SIGN_IN_INTERVAL_MS = 60000;
+  var SIGN_IN_WIDTH_PX = 690;
+  var SIGN_IN_HEIGHT_PX = 500;
+
   Polymer({
     is: 'gr-error-manager',
 
     properties: {
       _alertElement: Element,
       _hideAlertHandle: Number,
-      _hideAlertTimeout: {
-        type: Number,
-        value: 5000,
-      },
     },
 
     attached: function() {
@@ -67,7 +68,7 @@
 
       this._clearHideAlertHandle();
       this._hideAlertHandle =
-            this.async(this._hideAlert.bind(this), this._hideAlertTimeout);
+        this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
       var el = this._createToastAlert();
       el.show(text);
       this._alertElement = el;
@@ -88,12 +89,17 @@
     },
 
     _showAuthErrorAlert: function() {
+      // TODO(viktard): close alert if it's not for auth error.
       if (this._alertElement) { return; }
 
-      var el = this._createToastAlert();
-      el.addEventListener('action', this._refreshPage.bind(this));
-      el.show('Auth error', 'Refresh page');
-      this._alertElement = el;
+      this._alertElement = this._createToastAlert();
+      this._alertElement.show('Auth error', 'Refresh credentials.');
+      this.listen(this._alertElement, 'action', '_createLoginPopup');
+
+      if (typeof document.hidden !== undefined) {
+        this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+      }
+      this._requestCheckLoggedIn();
     },
 
     _createToastAlert: function() {
@@ -102,8 +108,44 @@
       return el;
     },
 
-    _refreshPage: function() {
-      window.location.reload();
+    _handleVisibilityChange: function() {
+      if (!document.hidden) {
+        this.flushDebouncer('checkLoggedIn');
+      }
+    },
+
+    _requestCheckLoggedIn: function() {
+      this.debounce(
+        'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
+    },
+
+    _checkSignedIn: function() {
+      this.$.restAPI.refreshCredentials().then(function(isLoggedIn) {
+        if (isLoggedIn) {
+          this._handleCredentialRefresh();
+        } else {
+          this._requestCheckLoggedIn();
+        }
+      }.bind(this));
+    },
+
+    _createLoginPopup: function(e) {
+      var left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
+      var top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
+      var options = [
+        'width=' + SIGN_IN_WIDTH_PX,
+        'height=' + SIGN_IN_HEIGHT_PX,
+        'left=' + left,
+        'top=' + top,
+      ];
+      window.open('/login/%3FcloseAfterLogin', '_blank', options.join(','));
+    },
+
+    _handleCredentialRefresh: function() {
+      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+      this.unlisten(this._alertElement, 'action', '_createLoginPopup');
+      this._hideAlert();
+      this._showAlert('Credentials refreshed.');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 08ce303..90e1ef8 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -33,27 +33,32 @@
 <script>
   suite('gr-error-manager tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(true); },
       });
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('show auth error', function(done) {
-      var showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      var showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
       element.fire('server-error', {response: {status: 403}});
       flush(function() {
         assert.isTrue(showAuthErrorStub.calledOnce);
-        showAuthErrorStub.restore();
         done();
       });
     });
 
     test('show normal server error', function(done) {
-      var showAlertStub = sinon.stub(element, '_showAlert');
-      var textSpy = sinon.spy(function() { return Promise.resolve('ZOMG'); });
+      var showAlertStub = sandbox.stub(element, '_showAlert');
+      var textSpy = sandbox.spy(function() { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
       assert.isTrue(textSpy.called);
@@ -61,14 +66,13 @@
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server error: ZOMG'));
-        showAlertStub.restore();
         done();
       });
     });
 
     test('show network error', function(done) {
-      var consoleErrorStub = sinon.stub(console, 'error');
-      var showAlertStub = sinon.stub(element, '_showAlert');
+      var consoleErrorStub = sandbox.stub(console, 'error');
+      var showAlertStub = sandbox.stub(element, '_showAlert');
       element.fire('network-error', {error: new Error('ZOMG')});
       flush(function() {
         assert.isTrue(showAlertStub.calledOnce);
@@ -76,10 +80,45 @@
             'Server unavailable'));
         assert.isTrue(consoleErrorStub.calledOnce);
         assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
-        showAlertStub.restore();
-        consoleErrorStub.restore();
         done();
       });
     });
+
+    test('show auth refresh toast', function(done) {
+      var refreshStub = sandbox.stub(element.$.restAPI, 'refreshCredentials',
+          function() { return Promise.resolve(true); });
+      var toastSpy = sandbox.spy(element, '_createToastAlert');
+      var windowOpen = sandbox.stub(window, 'open');
+      element.fire('server-error', {response: {status: 403}});
+      flush(function() {
+        assert.isTrue(toastSpy.called);
+        var toast = toastSpy.lastCall.returnValue;
+        assert.isOk(toast);
+        assert.include(
+            Polymer.dom(toast.root).textContent, 'Auth error');
+        assert.include(
+            Polymer.dom(toast.root).textContent, 'Refresh credentials.');
+
+        assert.isFalse(windowOpen.called);
+        toast.fire('action');
+        assert.isTrue(windowOpen.called);
+
+        var hideToastSpy = sandbox.spy(toast, 'hide');
+
+        assert.isFalse(refreshStub.called);
+        element.flushDebouncer('checkLoggedIn');
+        flush(function() {
+          assert.isTrue(refreshStub.called);
+          assert.isTrue(hideToastSpy.called);
+
+          assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
+          toast = toastSpy.lastCall.returnValue;
+          assert.isOk(toast);
+          assert.include(
+              Polymer.dom(toast.root).textContent, 'Credentials refreshed');
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 32246ca..9d8d409b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -40,6 +40,10 @@
 
     // Routes.
     page('/', loadUser, function(data) {
+      if (data.querystring.match(/^closeAfterLogin/)) {
+        // Close child window on redirect after login.
+        window.close();
+      }
       // For backward compatibility with GWT links.
       if (data.hash) {
         page.redirect(data.hash);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 25a432b..44976d3 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -250,6 +250,11 @@
       });
     },
 
+    refreshCredentials: function() {
+      this._cache['/accounts/self/detail'] = undefined;
+      return this.getLoggedIn();
+    },
+
     getPreferences: function() {
       return this.getLoggedIn().then(function(loggedIn) {
         if (loggedIn) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index a8932bf..e7f3e2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -33,29 +33,34 @@
 <script>
   suite('gr-rest-api-interface tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('JSON prefix is properly removed', function(done) {
       var testJSON = ')]}\'\n{"hello": "bonjour"}';
 
-      var fetchStub = sinon.stub(window, 'fetch', function() {
+      sandbox.stub(window, 'fetch', function() {
         return Promise.resolve({ok: true, text: function() {
           return Promise.resolve(testJSON);
         }});
       });
       element.fetchJSON('/dummy/url').then(function(obj) {
         assert.deepEqual(obj, {hello: 'bonjour'});
-        fetchStub.restore();
         done();
       });
     });
 
     test('cached results', function(done) {
       var n = 0;
-      var fetchJSONStub = sinon.stub(element, 'fetchJSON', function() {
+      sandbox.stub(element, 'fetchJSON', function() {
         return Promise.resolve(++n);
       });
       var promises = [];
@@ -67,7 +72,6 @@
         assert.deepEqual(results, [1, 1, 1]);
         element._fetchSharedCacheURL('/foo').then(function(foo) {
           assert.equal(foo, 1);
-          fetchJSONStub.restore();
           done();
         });
       });
@@ -105,7 +109,7 @@
 
     test('request callbacks can be canceled', function(done) {
       var cancelCalled = false;
-      var fetchStub = sinon.stub(window, 'fetch', function() {
+      sandbox.stub(window, 'fetch', function() {
         return Promise.resolve({body: {
           cancel: function() { cancelCalled = true; }
         }});
@@ -114,13 +118,12 @@
         function(obj) {
           assert.isUndefined(obj);
           assert.isTrue(cancelCalled);
-          fetchStub.restore();
           done();
         });
     });
 
     test('parent diff comments are properly grouped', function(done) {
-      var fetchJSONStub = sinon.stub(element, 'fetchJSON', function() {
+      sandbox.stub(element, 'fetchJSON', function() {
         return Promise.resolve({
           '/COMMIT_MSG': [],
           'sieve.go': [
@@ -147,13 +150,12 @@
             message: 'this isn’t quite right',
             path: 'sieve.go',
           });
-          fetchJSONStub.restore();
           done();
         });
     });
 
     test('differing patch diff comments are properly grouped', function(done) {
-      var fetchJSONStub = sinon.stub(element, 'fetchJSON', function(url) {
+      sandbox.stub(element, 'fetchJSON', function(url) {
         if (url == '/changes/42/revisions/1') {
           return Promise.resolve({
             '/COMMIT_MSG': [],
@@ -201,7 +203,6 @@
             message: '¯\\_(ツ)_/¯',
             path: 'sieve.go',
           });
-          fetchJSONStub.restore();
           done();
         });
     });
@@ -235,22 +236,21 @@
 
     test('rebase always enabled', function(done) {
       var resolveFetchJSON;
-      var fetchJSONStub = sinon.stub(element, 'fetchJSON').returns(
+      sandbox.stub(element, 'fetchJSON').returns(
           new Promise(function(resolve) {
             resolveFetchJSON = resolve;
           }));
       element.getChangeRevisionActions('42', '1337').then(
           function(response) {
             assert.isTrue(response.rebase.enabled);
-            fetchJSONStub.restore();
             done();
           });
       resolveFetchJSON({rebase: {}});
     });
 
     test('server error', function(done) {
-      var getResponseObjectStub = sinon.stub(element, 'getResponseObject');
-      var fetchStub = sinon.stub(window, 'fetch', function() {
+      var getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
+      sandbox.stub(window, 'fetch', function() {
         return Promise.resolve({ok: false});
       });
       var serverErrorEventPromise = new Promise(function(resolve) {
@@ -261,12 +261,37 @@
           function(response) {
             assert.isUndefined(response);
             assert.isTrue(getResponseObjectStub.notCalled);
-            getResponseObjectStub.restore();
-            fetchStub.restore();
             serverErrorEventPromise.then(function() {
               done();
             });
           });
     });
+
+    test('refreshCredentials', function(done) {
+      var responses = [
+        {
+          ok: false,
+          status: 403,
+          text: function() { return Promise.resolve(); }
+        },
+        {
+          ok: true,
+          status: 200,
+          text: function() { return Promise.resolve(')]}\'{}'); }
+        },
+      ];
+      var fetchStub = sandbox.stub(window, 'fetch', function(url) {
+        if (url === '/accounts/self/detail') {
+          return Promise.resolve(responses.shift());
+        }
+      });
+      element.getLoggedIn().then(function(isLoggedIn) {
+        assert.isFalse(isLoggedIn);
+        element.refreshCredentials().then(function(isRefreshed) {
+          assert.isTrue(isRefreshed);
+          done();
+        });
+      });
+    });
   });
 </script>