← Back to team overview

yellow team mailing list archive

[Merge] lp:~frankban/juju-gui/login-ui into lp:juju-gui

 

Francesco Banconi has proposed merging lp:~frankban/juju-gui/login-ui into lp:juju-gui.

Requested reviews:
  Juju GUI Hackers (juju-gui)
Related bugs:
  Bug #1097272 in juju-gui: "Implement username/password login UI"
  https://bugs.launchpad.net/juju-gui/+bug/1097272

For more details, see:
https://code.launchpad.net/~frankban/juju-gui/login-ui/+merge/142659

Improve login UX

Proposing and landing in place of 
https://codereview.appspot.com/7060066/
with fixes as per reviews.

https://codereview.appspot.com/7060068/

-- 
https://code.launchpad.net/~frankban/juju-gui/login-ui/+merge/142659
Your team Juju GUI Hackers is requested to review the proposed merge of lp:~frankban/juju-gui/login-ui into lp:juju-gui.
=== modified file 'app/app.js'
--- app/app.js	2013-01-09 15:55:25 +0000
+++ app/app.js	2013-01-10 10:33:20 +0000
@@ -244,7 +244,7 @@
       this.env.after('providerTypeChange', this.onProviderTypeChange);
 
       // Once the user logs in we need to redraw.
-      this.env.after('login', this.dispatch, this);
+      this.env.after('login', this.onLogin, this);
 
       // Feed environment changes directly into the database.
       this.env.on('delta', this.db.on_delta, this.db);
@@ -534,36 +534,51 @@
      *
      */
     check_user_credentials: function(req, res, next) {
-      var viewInfo = this.getViewInfo('login');
-      if (!viewInfo.instance) {
-        viewInfo.instance = new views.LoginView({
-          app: this,
-          env: this.env
-        });
+      if (!this.get('container').getDOMNode()) {
+        // We are probably after a test tear down, in a handler for a
+        // setTimeout in the YUI router.  Ugh, setTimeout is the source of
+        // so many fun race conditions and fragilities. :-/  Let's just bail.
+        return;
       }
-      var view = viewInfo.instance;
-      // If there are no stored credentials the user is prompted for some.
+      // If there are no stored credentials, the user is prompted for some.
       var user = this.env.get('user');
       var password = this.env.get('password');
       if (!Y.Lang.isValue(user) || !Y.Lang.isValue(password)) {
-        view.promptUser();
+        this.showView('login', {
+          env: this.env,
+          help_text: this.get('login_help')
+        });
       }
-      // If there are credentials available and there has not been a successful
-      // login attempt and we are not waiting on a login attempt, try to log in.
+      // If there are credentials available and there has not been
+      // a successful login attempt, try to log in.
       if (Y.Lang.isValue(user) && Y.Lang.isValue(password) &&
-          !this.env.waiting && !this.env.userIsAuthenticated) {
+          !this.env.userIsAuthenticated) {
         this.env.login();
         return;
       }
-      // If there has not been a successful login attempt, do not let the route
-      // dispatch proceed.
-      if (this.env.waiting || !this.env.userIsAuthenticated) {
+      // If there has not been a successful login attempt,
+      // do not let the route dispatch proceed.
+      if (!this.env.userIsAuthenticated) {
         return;
       }
       next();
     },
 
     /**
+     * Hide the login mask and redispatch the router.
+     *
+     * When the environment gets a response from a login attempt,
+     * it fires a login event, to which this responds.
+     *
+     * @method onLogin
+     * @private
+     */
+    onLogin: function() {
+      Y.one('body > #login-mask').hide();
+      this.dispatch();
+    },
+
+    /**
      * Display the provider type.
      *
      * The provider type arrives asynchronously.  Instead of updating the
@@ -573,11 +588,8 @@
      * @method onProviderTypeChange
      */
     onProviderTypeChange: function(evt) {
-      var providerType = evt.newVal,
-          providerNode = Y.one('#provider-type');
-      if (Y.Lang.isValue(providerType)) {
-        providerNode.set('text', 'on ' + providerType);
-      }
+      var providerType = evt.newVal;
+      Y.all('.provider-type').set('text', 'on ' + providerType);
     },
 
     /**

=== modified file 'app/config-debug.js'
--- app/config-debug.js	2012-12-05 12:33:09 +0000
+++ app/config-debug.js	2013-01-10 10:33:20 +0000
@@ -7,5 +7,6 @@
   // FIXME: turn off transitions until they are fixed.
   transitions: false,
   charm_store_url: 'http://jujucharms.com/',
-  socket_url: 'ws://localhost:8081/ws'
+  socket_url: 'ws://localhost:8081/ws',
+  login_help: 'For this demonstration, use the "admin" password to connect.'
 };

=== modified file 'app/config-prod.js'
--- app/config-prod.js	2012-12-13 02:43:15 +0000
+++ app/config-prod.js	2013-01-10 10:33:20 +0000
@@ -7,5 +7,8 @@
   // FIXME: turn off transitions until they are fixed.
   transitions: false,
   charm_store_url: 'http://jujucharms.com/',
-  socket_url: 'ws://localhost:8081/ws'
+  socket_url: 'ws://localhost:8081/ws',
+  login_help: (
+      'The password is the admin-secret from the Juju environment.  This can ' +
+      'often be found by looking in ~/.juju/environments.yaml.')
 };

=== modified file 'app/index.html'
--- app/index.html	2013-01-04 09:11:46 +0000
+++ app/index.html	2013-01-10 10:33:20 +0000
@@ -18,7 +18,7 @@
   </head>
 
   <body>
-
+      <div id="login-mask" class="crosshatch-background"></div>
       <div id="notifier-box"></div>
       <div id="viewport-wrapper">
         <div id="vp-left-border"></div>

=== modified file 'app/store/env.js'
--- app/store/env.js	2013-01-09 14:57:34 +0000
+++ app/store/env.js	2013-01-10 10:33:20 +0000
@@ -37,6 +37,7 @@
       this._txn_callbacks = {};
       // Consider the user unauthenticated until proven otherwise.
       this.userIsAuthenticated = false;
+      this.failedAuthentication = false;
       // When the server tells us the outcome of a login attempt we record
       // the result.
       this.on('login', this.handleLoginEvent, this);
@@ -100,11 +101,11 @@
     handleLoginEvent: function(evt) {
       // We are only interested in the responses to login events.
       this.userIsAuthenticated = !!evt.data.result;
-      this.waiting = false;
       // If the credentials were rejected remove them.
       if (!this.userIsAuthenticated) {
         this.set('user', undefined);
         this.set('password', undefined);
+        this.failedAuthentication = true;
       }
     },
 

=== added file 'app/templates/login.handlebars'
--- app/templates/login.handlebars	1970-01-01 00:00:00 +0000
+++ app/templates/login.handlebars	2013-01-10 10:33:20 +0000
@@ -0,0 +1,16 @@
+<div id="login-column">
+  <i class="sprite juju_logo" title="Juju GUI"></i>
+  <div id="login-form">
+    <div id="login-header">
+      {{environment_name}} <span class="provider-type">{{provider_type}}</span>
+    </div>
+    <form>
+      <input type="text" disabled="disabled" value="admin" />
+      <input type="password" value="" placeholder="Enter password" />
+      <input type="submit" value="Login" />
+    </form>
+  </div>
+  <div class="error">{{error_text}}</div>
+  <div>{{help_text}}</div>
+  <div><a href="https://juju.ubuntu.com/"; target="_blank">juju.ubuntu.com</a></div>
+</div>

=== modified file 'app/views/login.js'
--- app/views/login.js	2013-01-09 14:16:34 +0000
+++ app/views/login.js	2013-01-10 10:33:20 +0000
@@ -8,44 +8,75 @@
   var views = Y.namespace('juju.views');
 
   var LoginView = Y.Base.create('LoginView', Y.View, [views.JujuBaseView], {
-    // This is so tests can easily determine if the user was prompted.
-    _prompted: false,
-
-    /**
-     * Prompt the user for input.
-     *
-     * Does nothing if a special global flag has been set.  This is used so
-     * tests do not generate prompts.
-     *
-     * @method _prompt
-     * @param {String} message The message to display to the user.
-     * @return {String} The string the user typed.
-     */
-    _prompt: function(message) {
-      // noLogin is a global.
-      if (noLogin) {
-        return null;
-      }
-      return window.prompt(message);
-    },
-
-    /**
-     * Prompt the user for their user name and password.
-     *
-     * @method promptUser
-     * @return {undefined} Nothing.
-     */
-    promptUser: function() {
-      this._prompted = true;
-      var env = this.get('env');
-      // The user's name is always "admin".
-      env.set('user', 'admin');
-      env.set('password', this._prompt('Password'));
+    template: views.Templates.login,
+    events: {
+      '#login-form input[type=submit]': {click: 'login'}
+    },
+
+    /**
+     * Login event handler. When clicking the login form submit button,
+     * disable the form, take username and password from the input fields
+     * and put them in the environment, and call the environment login method.
+     *
+     * @method login
+     * @param {Object} ev An event object (with a "currentTarget" attribute).
+     * @return {undefined} Mutates only.
+    **/
+    login: function(ev) {
+      ev.preventDefault();
+      if (ev.currentTarget.get('disabled')) {
+        return;
+      }
+      var env = this.get('env');
+      var container = this.get('container');
+      container.all('input').set('disabled', true);
+      env.set('user', container.one('input[type=text]').get('value'));
+      env.set('password', container.one('input[type=password]').get('value'));
+      env.login();
+    },
+
+    /**
+     * Render the page.
+     *
+     * Reveal the mask element, and show the login form.
+     *
+     * @method render
+     * @return {undefined} Mutates only.
+    **/
+    render: function() {
+      // In order to have the mask cover everything, it needs to be an
+      // immediate child of the body.  In order for it to render immediately
+      // when the app loads, it needs to be in index.html.
+      var loginMask = Y.one('body > #login-mask');
+      if (!loginMask) {
+        // No login mask in the DOM, as is the case in tests.
+        return this;
+      }
+      loginMask.show();
+      var env = this.get('env');
+      var environment_name_node = Y.one('#environment-name');
+      var provider_type_node = Y.one('#provider-type');
+      var environment_name = (
+          environment_name_node ?
+          environment_name_node.get('text') : 'Environment');
+      var provider_type = (
+          provider_type_node ? provider_type_node.get('text') : '');
+      // In order to have events work and the view cleanly be replaced by
+      // other views, we need to put the contents in the usual "container"
+      // node, even though it is not a child of the mask node.
+      this.get('container').setHTML(this.template({
+        environment_name: environment_name,
+        provider_type: provider_type,
+        error_text: (
+            env.failedAuthentication ? 'Unknown user or password.' : ''),
+        help_text: this.get('help_text')
+      }));
+      return this;
     }
 
   });
 
-  views.LoginView = LoginView;
+  views.login = LoginView;
 
 }, '0.1.0', {
   requires: [
@@ -54,4 +85,3 @@
     'node'
   ]
 });
-

=== modified file 'lib/views/stylesheet.less'
--- lib/views/stylesheet.less	2013-01-08 14:02:06 +0000
+++ lib/views/stylesheet.less	2013-01-10 10:33:20 +0000
@@ -27,6 +27,7 @@
     margin-top: 87px;
     background-color: #302b28;
     font-family: @font-family;
+    height: 100%;
 }
 
 input, button, select, textarea {
@@ -222,7 +223,7 @@
     position: relative;
 }
 
-.zoom-plane { 
+.zoom-plane {
   fill-opacity: 0;
   cursor: move;
 }
@@ -1402,3 +1403,101 @@
     margin-top: 5px;
   }
 }
+
+#login-mask {
+    display: block;
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 10000;
+}
+#login-column {
+    display: block;
+    z-index: 10001;
+    position: absolute;
+    top: 80px;
+    left: 50%;
+    text-align: center;
+    width: 300px;
+    margin-left: -150px;
+
+    div {
+        width: 220px;
+        text-align: left;
+        margin: 0 auto;
+        margin-bottom: 1ex;
+    }
+    a {
+        color: @charm-panel-orange;
+        &:link, &:visited, &:active, &:hover {
+            text-decoration: none
+        }
+    }
+    @login-panel-color: #f5f5f5;
+    @login-panel-gradient: (@login-panel-color - #111);
+    @login-panel-shadow: (@login-panel-color - #444);
+    @curve-radius: ~"24px 28px";
+    div#login-form {
+        input {
+            margin-top: 8px;
+            margin-bottom: 8px;
+        }
+        input[type=submit] {
+            width: 220px;
+            height: 26px;
+            text-transform: uppercase;
+            text-shadow: none;
+            /* Needed to defeat bootstrap btn:hover 0 -15px */
+            background-position: 0 0;
+            .create-button(@charm-panel-deploy-button-color,
+                           @charm-panel-deploy-button-bottom-gradient,
+                           @charm-panel-deploy-button-shadow, 0px);
+            &:active {
+                .create-button(@charm-panel-deploy-button-color,
+                               @charm-panel-deploy-button-color,
+                               @charm-panel-deploy-button-shadow, 1px);
+            }
+            color: white;
+        }
+        padding: 5px 25px 5px;
+        margin-top: 3ex;
+        margin-bottom: 4ex;
+        .create-button(@login-panel-color,
+                       @login-panel-gradient,
+                       @login-panel-shadow,
+                       -6px);
+        .create-border-radius(~"24px / 28px");
+        div#login-header {
+            padding: 15px 25px 10px 25px;
+            margin-left: -25px;
+            margin-top: -5px;
+            -webkit-border-top-left-radius: @curve-radius;
+            -webkit-border-top-right-radius: @curve-radius;
+            -moz-border-radius-topleft: @curve-radius;
+            -moz-border-radius-topright: @curve-radius;
+            border-top-left-radius: @curve-radius;
+            border-top-right-radius: @curve-radius;
+            @gradient-start: @login-panel-gradient;
+            @gradient-end: @login-panel-shadow;
+            background-image: -ms-linear-gradient(
+                top, @gradient-start, @gradient-end);
+            background-image: -webkit-gradient(
+                linear, 0 0, 0 100%, from(@gradient-start), to(@gradient-end));
+            background-image: -webkit-linear-gradient(
+                top, @gradient-start, @gradient-end);
+            background-image: -o-linear-gradient(
+                top, @gradient-start, @gradient-end);
+            background-image: -moz-linear-gradient(
+                top, @gradient-start, @gradient-end);
+            background-image: linear-gradient(
+                top, @gradient-start, @gradient-end);
+            background-repeat: repeat-x;
+        }
+    }
+    .error {
+        color: red;
+        font-weight: bold;
+    }
+}

=== modified file 'test/index.html'
--- test/index.html	2013-01-09 15:55:25 +0000
+++ test/index.html	2013-01-10 10:33:20 +0000
@@ -38,7 +38,7 @@
   <script src="test_charm_panel.js"></script>
   <script src="test_charm_store.js"></script>
   <script src="test_charm_view.js"></script>
-  <script src="test_console.js"></script> 
+  <script src="test_console.js"></script>
   <script src="test_d3_components.js"></script>
   <script src="test_environment_view.js"></script>
   <script src="test_env.js"></script>
@@ -72,8 +72,7 @@
 </head>
 
 <body>
-  <div id="main" class="container">
-  </div>
+  <div id="main" class="container"></div>
   <div id="mocha"></div>
 </body>
 </html>

=== modified file 'test/test_app.js'
--- test/test_app.js	2013-01-09 15:55:25 +0000
+++ test/test_app.js	2013-01-10 10:33:20 +0000
@@ -52,7 +52,7 @@
           .append(Y.Node.create('<span/>')
             .set('id', 'environment-name'))
           .append(Y.Node.create('<span/>')
-            .set('id', 'provider-type'))
+            .addClass('provider-type'))
           .hide();
       app = new Y.juju.App(
           { container: container,
@@ -115,12 +115,12 @@
     it('should show the provider type, when available', function() {
       var providerType = 'excellent provider';
       // Since no provider type has been set yet, none is displayed.
-      assert.equal('', container.one('#provider-type').get('text'));
+      assert.equal('', container.one('.provider-type').get('text'));
       app.env.set('providerType', providerType);
       // The provider type has been displayed.
       assert.equal(
           'on ' + providerType,
-          container.one('#provider-type').get('text')
+          container.one('.provider-type').get('text')
       );
     });
 

=== modified file 'test/test_login.js'
--- test/test_login.js	2013-01-09 15:55:25 +0000
+++ test/test_login.js	2013-01-10 10:33:20 +0000
@@ -4,17 +4,18 @@
 
   describe('environment login support', function() {
     var requires = ['node', 'juju-gui', 'juju-views', 'juju-tests-utils'];
-    var Y, conn, env, utils, juju, makeLoginView, views, app;
+    var Y, conn, env, utils, juju;
     var test = it; // We aren't really doing BDD so let's be more direct.
 
-    before(function() {
-      Y = YUI(GlobalConfig).use(requires);
-      utils = Y.namespace('juju-tests.utils');
-      juju = Y.namespace('juju');
+    before(function(done) {
+      Y = YUI(GlobalConfig).use(requires, function(Y) {
+        utils = Y.namespace('juju-tests').utils;
+        juju = Y.namespace('juju');
+        done();
+      });
     });
 
     beforeEach(function() {
-      var container = Y.Node.create('<div/>');
       conn = new utils.SocketStub();
       env = new juju.Environment({conn: conn});
       env.connect();
@@ -82,4 +83,59 @@
 
   });
 
+
+  describe('login view', function() {
+    var requires = ['node', 'juju-gui', 'juju-views', 'juju-tests-utils'];
+    var Y, conn, env, utils, juju, views, loginView, container, loginMask;
+    var test = it; // We aren't really doing BDD so let's be more direct.
+
+    before(function(done) {
+      Y = YUI(GlobalConfig).use(requires, function(Y) {
+        utils = Y.namespace('juju-tests').utils;
+        juju = Y.namespace('juju');
+        views = Y.namespace('juju.views');
+        done();
+      });
+    });
+
+    beforeEach(function() {
+      conn = new utils.SocketStub();
+      env = new juju.Environment({conn: conn});
+      env.connect();
+      conn.open();
+      container = Y.one('body').appendChild('<div/>');
+      // Needed by the render method.
+      loginMask = Y.one('body').appendChild('<div/>').set('id', 'login-mask');
+      loginView = new views.login(
+          {container: container, env: env, help_text: 'Help text'});
+    });
+
+    afterEach(function() {
+      env.destroy();
+      container.remove(true);
+      loginMask.remove(true);
+    });
+
+    test('the view login method logs in through the environment', function() {
+      var noop = function() {};
+      var ev = {preventDefault: noop, currentTarget: {get: noop}};
+      container.appendChild('<input/>').set('type', 'text').set(
+          'value', 'user');
+      container.appendChild('<input/>').set('type', 'password').set(
+          'value', 'password');
+      loginView.login(ev);
+      assert.equal(conn.last_message().op, 'login');
+      assert.equal(conn.last_message().user, 'user');
+      assert.equal(conn.last_message().password, 'password');
+    });
+
+    test('the view render method adds the login form', function() {
+      loginView.render();
+      assert.equal(
+          container.one('#login-form input[type=submit]').get('value'),
+          'Login');
+    });
+
+  });
+
 })();


Follow ups