yellow team mailing list archive
-
yellow team
-
Mailing list archive
-
Message #02252
[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