← Back to team overview

yellow team mailing list archive

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

 

Benji York has proposed merging lp:~benji/juju-gui/login into lp:juju-gui.

Requested reviews:
  Juju GUI Hackers (juju-gui)
Related bugs:
  Bug #1074423 in juju-gui: "Enable user/password login"
  https://bugs.launchpad.net/juju-gui/+bug/1074423

For more details, see:
https://code.launchpad.net/~benji/juju-gui/login/+merge/141133

Add user login support.

Future work: we need to implement a real UI for this.  At the moment this
branch uses prompt() calls to generate modal dialogs.  Also, this will
probably need slight tweaking once the backend actually requires
authorization.

https://codereview.appspot.com/7007047/

-- 
https://code.launchpad.net/~benji/juju-gui/login/+merge/141133
Your team Juju GUI Hackers is requested to review the proposed merge of lp:~benji/juju-gui/login into lp:juju-gui.
=== modified file 'app/app.js'
--- app/app.js	2012-12-19 13:45:10 +0000
+++ app/app.js	2012-12-21 20:53:25 +0000
@@ -29,6 +29,11 @@
         preserve: true
       },
 
+      login: {
+        type: 'juju.views.login',
+        preserve: true
+      },
+
       service: {
         type: 'juju.views.service',
         preserve: false,
@@ -517,6 +522,32 @@
     },
 
     /**
+     * Ensure that the current user has authenticated.
+     *
+     * @method check_user_credentials
+     * @param {Object} req The request.
+     * @param {Object} res Something. ???
+     * @param {Object} res ???
+     * @param {Object} next The next route handler.
+     *
+     */
+    check_user_credentials: function(req, res, next) {
+      var viewInfo = this.getViewInfo('login');
+      if (!viewInfo.instance) {
+        viewInfo.instance = new views.LoginView({
+          env: this.env
+        });
+      }
+      var view = viewInfo.instance;
+      // If there has not been a successful login attempt, prompt for
+      // credentials.
+      if (!view.waiting && !view.userIsAuthenticated) {
+        view.login();
+      }
+      next();
+    },
+
+    /**
      * Display the provider type.
      *
      * The provider type arrives asynchronously.  Instead of updating the
@@ -709,6 +740,7 @@
       charm_store: {},
       routes: {
         value: [
+          { path: '*', callback: 'check_user_credentials'},
           { path: '*', callback: 'show_notifications_view'},
           { path: '/charms/', callback: 'show_charm_collection'},
           { path: '/charms/*charm_store_path',
@@ -746,6 +778,7 @@
 }, '0.5.2', {
   requires: [
     'juju-models',
+    'juju-notification-controller',
     'juju-charm-models',
     'juju-views',
     'juju-controllers',

=== modified file 'app/modules-debug.js'
--- app/modules-debug.js	2012-12-21 12:52:30 +0000
+++ app/modules-debug.js	2012-12-21 20:53:25 +0000
@@ -96,6 +96,10 @@
           fullpath: '/juju-ui/views/environment.js'
         },
 
+        'juju-view-login': {
+          fullpath: '/juju-ui/views/login.js'
+        },
+
         'juju-view-service': {
           fullpath: '/juju-ui/views/service.js'
         },
@@ -124,6 +128,7 @@
             'juju-view-utils',
             'juju-topology',
             'juju-view-environment',
+            'juju-view-login',
             'juju-view-service',
             'juju-view-unit',
             'juju-view-charm',

=== modified file 'app/modules-prod.js'
--- app/modules-prod.js	2012-12-13 02:43:15 +0000
+++ app/modules-prod.js	2012-12-21 20:53:25 +0000
@@ -17,6 +17,7 @@
             'juju-topology',
             'juju-view-utils',
             'juju-view-environment',
+            'juju-view-login',
             'juju-view-service',
             'juju-view-unit',
             'juju-view-charm',

=== modified file 'app/store/env.js'
--- app/store/env.js	2012-10-15 13:20:38 +0000
+++ app/store/env.js	2012-12-21 20:53:25 +0000
@@ -64,20 +64,19 @@
     on_close: function(data) {
       console.log('Env: Disconnect');
       this.set('connected', false);
+      this.set('serverReady', false);
     },
 
     on_message: function(evt) {
       console.log('Env: Receive', evt.data);
-
       var msg = Y.JSON.parse(evt.data);
+      // The "ready" attribute indicates that this is a server's initial
+      // greeting.  It provides a few initial values that we care about.
       if (msg.ready) {
-        // The "ready" attribute indicates that this is a server's initial
-        // greeting.  It provides a few initial values that we care about.
+        console.log('Env: Handshake Complete');
+        this.set('serverReady', true);
         this.set('providerType', msg.provider_type);
         this.set('defaultSeries', msg.default_series);
-      }
-      if (msg.version === 0) {
-        console.log('Env: Handshake Complete');
         return;
       }
       this.fire('msg', msg);
@@ -174,6 +173,18 @@
       this._send_rpc({'op': 'expose', 'service_name': service}, callback);
     },
 
+    /**
+     * Attempt to log the user in.
+     * @param {Object} user The user name.
+     * @param {Object} password The user's password.
+     * @param {Object} [callback] A function to call when a response to the
+     *   message arrives.
+     * @return {undefined} Nothing.
+     */
+    login: function(user, password, callback) {
+      this._send_rpc({op: 'login', user: user, password: password}, callback);
+    },
+
     unexpose: function(service, callback) {
       this._send_rpc({'op': 'unexpose', 'service_name': service}, callback);
     },

=== added file 'app/views/login.js'
--- app/views/login.js	1970-01-01 00:00:00 +0000
+++ app/views/login.js	2012-12-21 20:53:25 +0000
@@ -0,0 +1,124 @@
+'use strict';
+
+var no_login_prompts = false;
+
+YUI.add('juju-view-login', function(Y) {
+
+  var views = Y.namespace('juju.views');
+  var Templates = views.Templates;
+
+  var LoginView = Y.Base.create('LoginView', Y.View, [views.JujuBaseView], {
+    // This is so tests can easily determine if the user was prompted.
+    _prompted: false,
+
+    /**
+     * Initialize the login view.
+     *
+     * @return {undefined} Nothing.
+     */
+    initializer: function() {
+      this.userIsAuthenticated = false;
+      // When the server tells us the outcome of a login attempt we record
+      // the result.
+      this.get('env').after('log', this.handleLoginEvent, this);
+    },
+
+    /**
+     * React to the results of sennding a login message to the server.
+     *
+     * @method handleLoginEvent
+     * @param {Object} evt The event to which we are responding.
+     * @return {undefined} Nothing.
+     */
+    handleLoginEvent: function(evt) {
+      // We are only interested in the responses to login events.
+      if (evt.data.op !== 'login') {
+        return;
+      }
+      this.userIsAuthenticated = !!evt.data.result;
+      this.waiting = 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) {
+      // no_login_prompts is a global.
+      if (no_login_prompts) {
+        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;
+      this.set('user', this._prompt('User name'));
+      this.set('password', this._prompt('Password'));
+      this.waiting = true;
+    },
+
+    /**
+     * Send a login RPC message to the server.
+     *
+     * @method validateCredentials
+     * @param {String} user The user name.
+     * @param {String} password The password.
+     * @return {undefined} Nothing.
+     */
+    validateCredentials: function(user, password) {
+      this.get('env').login(user, password);
+    },
+
+    /**
+     * Attempt to log the user in.
+     *
+     * If a login attempt is outstanding, this function is a no-op.
+     *
+     * @method login
+     * @return {undefined} Nothing.
+     */
+    login: function() {
+      // If the server connection is not yet ready, then there is no use in
+      // trying to authenticate.
+      var env = this.get('env');
+      if (!env.get('serverReady')) {
+        return;
+      }
+      // If the credentials are known good or we are waiting to find out, exit
+      // early.
+      if (this.userIsAuthenticated || this.waiting) {
+        return;
+      }
+      // If there are no stored credentials, prompt the user for some.
+      if (!Y.Lang.isValue(this.get('user'))) {
+        this.promptUser();
+      }
+      this.validateCredentials(this.get('user'), this.get('password'));
+    }
+
+  });
+
+  views.LoginView = LoginView;
+
+}, '0.1.0', {
+  requires: [
+    'view',
+    'juju-view-utils',
+    'node',
+    'handlebars'
+  ]
+});
+

=== modified file 'test/index.html'
--- test/index.html	2012-12-17 14:53:46 +0000
+++ test/index.html	2012-12-21 20:53:25 +0000
@@ -16,7 +16,31 @@
     var assert = chai.assert,
         expect = chai.expect
         should = chai.should();
-    mocha.setup({'ui': 'bdd', 'ignoreLeaks': false})
+    mocha.setup({ui: 'bdd', ignoreLeaks: false})
+  </script>
+
+  <script>
+  YUI().use('node', 'event', function(Y) {
+     no_login_prompts = true;
+     var config = GlobalConfig;
+     for (group in config.groups) {
+       var group = config.groups[group];
+       for (m in group.modules) {
+         var resource = group.modules[m];
+         if (!m || !resource.fullpath) {
+           continue
+         }
+         resource.fullpath = resource.fullpath.replace(
+           '/juju-ui/', '../juju-ui/', 1);
+         // If we load modules asyncronously then the module loading may take
+         // so long that the test definitions (and before/after calls) happen
+         // *after* the test runner is invoked.  In which case the test runner
+         // will not know about the tests and therefore not run them.
+         resource.async = false;
+       }
+     }
+     Y.on('domready', mocha.run);
+  });
   </script>
 
   <script src="test_d3_components.js"></script>
@@ -32,6 +56,7 @@
   <script src="test_service_config_view.js"></script>
   <script src="test_service_view.js"></script>
   <script src="test_utils.js"></script>
+  <script src="test_login_view.js"></script>
   <script src="test_charm_panel.js"></script>
   <script src="test_charm_configuration.js"></script>
   <script src="test_console.js"></script>
@@ -41,27 +66,6 @@
   <script src="test_app_hotkeys.js"></script>
   <script src="test_notifier_widget.js"></script>
 
-  <script>
-  YUI().use('node', 'event', function(Y) {
-     Y.on('domready', function() {
-
-     var config = GlobalConfig;
-     for (group in config.groups) {
-          var group = config.groups[group];
-         for (m in group.modules) {
-            var resource = group.modules[m];
-            if (!m || !resource.fullpath) {
-              continue
-            }
-            resource.fullpath = resource.fullpath.replace(
-              '/juju-ui/', '../juju-ui/', 1);
-         }
-     }
-     // Load before test runner
-     mocha.run();
-     });
-  });
-  </script>
 
 </head>
 

=== added file 'test/test_login_view.js'
--- test/test_login_view.js	1970-01-01 00:00:00 +0000
+++ test/test_login_view.js	2012-12-21 20:53:25 +0000
@@ -0,0 +1,225 @@
+'use strict';
+
+(function() {
+
+  var requires = ['node', 'juju-gui', 'juju-views', 'juju-tests-utils'];
+  var Y = YUI(GlobalConfig).use(requires);
+
+  describe('login view', function() {
+    var conn, env, utils, juju, makeLoginView, views, app;
+    var test = it; // We aren't really doing BDD so let's be more direct.
+
+    before(function() {
+      views = Y.namespace('juju.views');
+      utils = Y.namespace('juju-tests.utils');
+      juju = Y.namespace('juju');
+      makeLoginView = function() {
+        var view = new views.LoginView({env: env});
+        return view;
+      };
+    });
+
+    beforeEach(function() {
+      var container = Y.Node.create('<div/>');
+      conn = new utils.SocketStub();
+      env = new juju.Environment({conn: conn});
+      env.connect();
+      conn.open();
+      env.set('serverReady', true);
+    });
+
+    afterEach(function() {
+      env.destroy();
+    });
+
+    test('login view can be instantiated', function() {
+      var view = makeLoginView();
+    });
+
+    test('credentials are initially assumed invalid', function() {
+      var view = makeLoginView();
+      assert.equal(view.userIsAuthenticated, false);
+    });
+
+    test('successful login event marks user as authenticated', function() {
+      var view = makeLoginView();
+      var evt = {data: {op: 'login', result: true}};
+      view.handleLoginEvent(evt);
+      assert.equal(view.userIsAuthenticated, true);
+    });
+
+    test('unsuccessful login event marks user as unauthenticated', function() {
+      var view = makeLoginView();
+      var evt = {data: {op: 'login'}};
+      view.handleLoginEvent(evt);
+      assert.equal(view.userIsAuthenticated, false);
+    });
+
+    test('credentials passed to the constructor are stored', function() {
+      var fauxEnvironment = {
+        after: function() {
+        }
+      };
+      var user = 'Will Smith';
+      var password = 'I am legend!';
+      var view = new views.LoginView({
+        user: user,
+        password: password,
+        env: fauxEnvironment
+      });
+      assert.equal(view.get('user'), user);
+      assert.equal(view.get('password'), password);
+    });
+
+    test('prompts when there are no known credentials', function() {
+      var view = makeLoginView();
+      view.login();
+      assert.isTrue(view._prompted);
+    });
+
+    test('no prompt when there are known credentials', function() {
+      var view = makeLoginView();
+      view.set('user', 'user');
+      view.set('password', 'password');
+      view.login();
+      assert.isFalse(view._prompted);
+    });
+
+    test('login() sends login message', function() {
+      var view = makeLoginView();
+      view.set('user', 'user');
+      view.set('password', 'password');
+      view.login();
+      assert.equal(conn.last_message().op, 'login');
+    });
+
+  });
+
+
+  describe('login user interaction', function() {
+    var conn, env, utils, juju, makeLoginView, views, app;
+    var test = it; // We aren't really doing BDD so let's be more direct.
+
+    before(function() {
+      views = Y.namespace('juju.views');
+      utils = Y.namespace('juju-tests.utils');
+      juju = Y.namespace('juju');
+      makeLoginView = function() {
+        var view = new views.LoginView({env: env});
+        return view;
+      };
+    });
+
+    beforeEach(function() {
+      var container = Y.Node.create('<div/>');
+      conn = new utils.SocketStub();
+      env = new juju.Environment({conn: conn});
+      env.connect();
+      conn.open();
+      env.set('serverReady', true);
+      app = new Y.juju.App({env: env, container: container});
+    });
+
+    afterEach(function() {
+      env.destroy();
+    });
+
+    test('the login checker is registered first for all routes', function() {
+      assert.equal(app.match('/')[0].callback, 'check_user_credentials');
+    });
+
+    test('unauthorized requests prompt for credentials', function() {
+      var view = app.getViewInfo('login').instance = makeLoginView();
+      app.check_user_credentials(undefined, undefined, function() {});
+      assert.isTrue(view._prompted);
+    });
+
+    test('user is not prompted while waiting for login results', function() {
+      var view = app.getViewInfo('login').instance = makeLoginView();
+      app.check_user_credentials(undefined, undefined, function() {});
+      assert.isTrue(view._prompted);
+      assert.isTrue(view.waiting);
+      view._prompted = false;
+      app.check_user_credentials(undefined, undefined, function() {});
+      assert.isFalse(view._prompted);
+    });
+
+    // If there are know credentials that are not known to be bad (they are
+    // either good or not yet validated) and a login request is made, no
+    // prompting is done, instead the existing credentials are reused, or if we
+    // are waiting on credential validation, no action is taken.
+    test('no prompting if non-invalid credentials are available', function() {
+      var view = app.getViewInfo('login').instance = makeLoginView();
+      app.check_user_credentials(undefined, undefined, function() {});
+      assert.isTrue(view._prompted);
+      assert.isTrue(view.waiting);
+      view._prompted = false;
+      app.check_user_credentials(undefined, undefined, function() {});
+      assert.isFalse(view._prompted);
+    });
+
+  });
+
+
+  describe('login server interaction', function() {
+    var conn, env, utils, juju, makeLoginView, views, app;
+    var test = it; // We aren't really doing BDD so let's be more direct.
+
+    before(function() {
+      views = Y.namespace('juju.views');
+      utils = Y.namespace('juju-tests.utils');
+      juju = Y.namespace('juju');
+      makeLoginView = function() {
+        var view = new views.LoginView({env: env});
+        return view;
+      };
+    });
+
+    beforeEach(function() {
+      var container = Y.Node.create('<div/>');
+      conn = new utils.SocketStub();
+      env = new juju.Environment({conn: conn});
+      env.connect();
+      conn.open();
+      env.set('serverReady', true);
+      app = new Y.juju.App({env: env, container: container});
+    });
+
+    afterEach(function() {
+      env.destroy();
+    });
+
+    test('the login method actually sends a message ', function() {
+      env.login('user', 'pass');
+      assert.equal(conn.last_message().op, 'login');
+    });
+
+    test('the view contacts the server to verify credentials', function() {
+      var view = makeLoginView();
+      var user = 'Will Smith';
+      var password = 'I am legend!';
+      // If we ask the view to validate credentials, a login message will be
+      // sent to the server.
+      view.validateCredentials(user, password);
+      var msg = conn.last_message();
+      assert.equal(msg.op, 'login');
+      assert.equal(msg.user, user);
+      assert.equal(msg.password, password);
+    });
+
+    test('successful credential verification messages are handled', function() {
+      var view = makeLoginView();
+      assert.isFalse(view.userIsAuthenticated);
+      env.fire('log', {data: {op: 'login', result: true}});
+      assert.isTrue(view.userIsAuthenticated);
+    });
+
+    test('failed credential verification messages are handled', function() {
+      var view = makeLoginView();
+      assert.isFalse(view.userIsAuthenticated);
+      env.fire('log', {op: 'login', data: {}});
+      assert.isFalse(view.userIsAuthenticated);
+    });
+
+  });
+})();

=== modified file 'test/test_service_view.js'
--- test/test_service_view.js	2012-11-08 18:16:07 +0000
+++ test/test_service_view.js	2012-12-21 20:53:25 +0000
@@ -23,7 +23,8 @@
       env = new (Y.namespace('juju')).Environment({conn: conn});
       env.connect();
       conn.open();
-      container = Y.Node.create('<div id="test-container" />');
+      container = Y.Node.create('<div/>')
+        .hide();
       Y.one('#main').append(container);
       db = new models.Database();
       charm = new models.Charm({id: 'cs:precise/mysql-7', description: 'A DB'});


Follow ups