diff --git a/doc/source/configuration/settings.rst b/doc/source/configuration/settings.rst index 814bee020b..66ec835446 100644 --- a/doc/source/configuration/settings.rst +++ b/doc/source/configuration/settings.rst @@ -48,7 +48,7 @@ Default: { 'images_panel': True, - 'key_pairs_panel': False, + 'key_pairs_panel': True, 'flavors_panel': False, 'domains_panel': False, 'users_panel': False, diff --git a/horizon/static/framework/widgets/action-list/actions-batch.template.html b/horizon/static/framework/widgets/action-list/actions-batch.template.html index 7fa8e20c1e..5920809d95 100644 --- a/horizon/static/framework/widgets/action-list/actions-batch.template.html +++ b/horizon/static/framework/widgets/action-list/actions-batch.template.html @@ -1,3 +1,4 @@ + $text$ diff --git a/horizon/static/framework/widgets/action-list/actions-row.template.html b/horizon/static/framework/widgets/action-list/actions-row.template.html index b6828c8d48..33b59bee31 100644 --- a/horizon/static/framework/widgets/action-list/actions-row.template.html +++ b/horizon/static/framework/widgets/action-list/actions-row.template.html @@ -1,3 +1,4 @@ + $text$ diff --git a/horizon/static/framework/widgets/action-list/actions.service.js b/horizon/static/framework/widgets/action-list/actions.service.js index da5d7ca3e4..6b9cadf215 100644 --- a/horizon/static/framework/widgets/action-list/actions.service.js +++ b/horizon/static/framework/widgets/action-list/actions.service.js @@ -208,6 +208,7 @@ .replace( '$action-classes$', getActionClasses(action, index, permittedActions.length) ) + .replace('$icon$', action.template.icon) .replace('$text$', action.template.text) .replace('$title$', action.template.title) .replace('$description$', action.template.description) diff --git a/horizon/static/framework/widgets/load-edit/load-edit.directive.js b/horizon/static/framework/widgets/load-edit/load-edit.directive.js index 902c11f1e9..7b5e94babc 100644 --- a/horizon/static/framework/widgets/load-edit/load-edit.directive.js +++ b/horizon/static/framework/widgets/load-edit/load-edit.directive.js @@ -52,7 +52,8 @@ maxBytes: '@', key: '@', required: '=', - rows: '@' + rows: '@', + onTextareaChange: '&' }, link: link, templateUrl: basePath + 'load-edit.html' @@ -113,6 +114,7 @@ } else { $scope.textModified = false; } + $scope.onTextareaChange({textContent: $scope.textContent}); }); } diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index e4f4d3718a..3d6810ac6e 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -325,7 +325,7 @@ COMPRESS_OFFLINE_CONTEXT = 'horizon.themes.offline_context' # Dictionary of currently available angular features ANGULAR_FEATURES = { 'images_panel': True, - 'key_pairs_panel': False, + 'key_pairs_panel': True, 'flavors_panel': False, 'domains_panel': False, 'users_panel': False, diff --git a/openstack_dashboard/static/app/core/keypairs/_keypairs.scss b/openstack_dashboard/static/app/core/keypairs/_keypairs.scss index 57b15c80f9..d8012004c0 100644 --- a/openstack_dashboard/static/app/core/keypairs/_keypairs.scss +++ b/openstack_dashboard/static/app/core/keypairs/_keypairs.scss @@ -21,3 +21,7 @@ hz-details { } } } + +textarea#public_key { + height: 22em; +} \ No newline at end of file diff --git a/openstack_dashboard/static/app/core/keypairs/actions/actions.module.js b/openstack_dashboard/static/app/core/keypairs/actions/actions.module.js index e37262932c..c3e7ce8cff 100644 --- a/openstack_dashboard/static/app/core/keypairs/actions/actions.module.js +++ b/openstack_dashboard/static/app/core/keypairs/actions/actions.module.js @@ -30,16 +30,37 @@ registerKeypairActions.$inject = [ 'horizon.framework.conf.resource-type-registry.service', + 'horizon.app.core.keypairs.actions.create.service', + 'horizon.app.core.keypairs.actions.import.service', 'horizon.app.core.keypairs.actions.delete.service', 'horizon.app.core.keypairs.resourceType' ]; function registerKeypairActions( registry, + createKeypairService, + importKeypairService, deleteKeypairService, resourceType ) { var keypairResourceType = registry.getResourceType(resourceType); + keypairResourceType.globalActions + .append({ + id: 'createKeypairService', + service: createKeypairService, + template: { + type: 'create', + text: gettext('Create Key Pair') + } + }) + .append({ + id: 'importKeypairService', + service: importKeypairService, + template: { + text: gettext('Import Public Key'), + icon: 'upload' + } + }); keypairResourceType.batchActions .append({ id: 'batchDeleteKeypairAction', diff --git a/openstack_dashboard/static/app/core/keypairs/actions/create.description.html b/openstack_dashboard/static/app/core/keypairs/actions/create.description.html new file mode 100644 index 0000000000..28b8ad924b --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/create.description.html @@ -0,0 +1,5 @@ +

+ Key Pairs are how you login to your instance after it is launched. + Choose a key pair name you will recognize. + Names may only include alphanumeric characters, spaces, or dashes. +

\ No newline at end of file diff --git a/openstack_dashboard/static/app/core/keypairs/actions/create.service.js b/openstack_dashboard/static/app/core/keypairs/actions/create.service.js new file mode 100644 index 0000000000..c65c6dd414 --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/create.service.js @@ -0,0 +1,159 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + /** + * @ngdoc overview + * @name horizon.app.core.keypairs.create.service + * @description Service for the key pair create modal + */ + angular + .module('horizon.app.core.keypairs.actions') + .factory('horizon.app.core.keypairs.actions.create.service', createService); + + createService.$inject = [ + 'horizon.app.core.keypairs.basePath', + 'horizon.app.core.keypairs.resourceType', + 'horizon.app.core.openstack-service-api.nova', + 'horizon.app.core.openstack-service-api.policy', + 'horizon.framework.util.actions.action-result.service', + 'horizon.framework.util.file.text-download', + 'horizon.framework.widgets.form.ModalFormService', + 'horizon.framework.widgets.toast.service' + ]; + + function createService( + basePath, resourceType, nova, policy, actionResult, download, modal, toast + ) { + + var keypairs = []; + var caption = gettext("Create Key Pair"); + var invalidMsg = gettext("Key pair already exists."); + + // schema + var schema = { + type: "object", + properties: { + "name": { + title: gettext("Key Pair Name"), + type: "string", + pattern: "^[A-Za-z0-9 -]+$" + } + } + }; + + // form + var form = [ + { + type: "section", + htmlClass: "row", + items: [ + { + type: "section", + htmlClass: "col-sm-6", + items: [ + { + key: "name", + validationMessage: { + keypairExists: invalidMsg + }, + $validators: { + keypairExists: function (name) { + return (keypairs.indexOf(name) === -1); + } + }, + required: true + } + ] + }, + { + type: "section", + htmlClass: "col-sm-6", + items: [ + { + type: "template", + templateUrl: basePath + "actions/create.description.html" + } + ] + } + ] + } + ]; + + // model + var model; + + var service = { + perform: perform, + allowed: allowed, + getKeypairs: getKeypairs + }; + + return service; + + ////////////// + + function allowed() { + return policy.ifAllowed({ rules: [['compute', 'os_compute_api:os-keypairs:create']] }); + } + + function perform() { + getKeypairs(); + model = { + name: "" + }; + var config = { + "title": caption, + "submitText": caption, + "schema": schema, + "form": form, + "model": model, + "submitIcon": "plus" + }; + return modal.open(config).then(submit); + } + + function submit(context) { + return nova.createKeypair(context.model).then(success); + } + + /** + * @ngdoc function + * @name success + * @description + * Informs the user about the created key pair. + * @param {Object} keypair The new key pair object + * @returns {undefined} No return value + */ + function success(response) { + var successMsg = gettext('Key pair %(name)s was successfully created.'); + toast.add('success', interpolate(successMsg, { name: response.data.name }, true)); + download.downloadTextFile(response.data.private_key, response.data.name + '.pem'); + var result = actionResult.getActionResult().created(resourceType, response.data.name); + return result.result; + } + + function getKeypairs() { + nova.getKeypairs().then(function(response) { + keypairs = response.data.items.map(getName); + }); + } + + function getName(item) { + return item.keypair.name; + } + } +})(); diff --git a/openstack_dashboard/static/app/core/keypairs/actions/create.service.spec.js b/openstack_dashboard/static/app/core/keypairs/actions/create.service.spec.js new file mode 100644 index 0000000000..afbbf39ce8 --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/create.service.spec.js @@ -0,0 +1,74 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + describe('horizon.app.core.keypairs.actions.create.service', function() { + + var service, nova, $q, $scope, deferred, deferredKeypairs, deferredNewKeypair, toast; + var model = { + name: "Hiroshige" + }; + var modal = { + open: function (config) { + config.model = model; + deferred = $q.defer(); + deferred.resolve(config); + return deferred.promise; + } + }; + + /////////////////// + + beforeEach(module('horizon.app.core')); + beforeEach(module('horizon.framework')); + beforeEach(module('horizon.app.core.keypairs.actions')); + + beforeEach(module(function($provide) { + $provide.value('horizon.framework.widgets.form.ModalFormService', modal); + })); + + beforeEach(inject(function($injector, _$rootScope_, _$q_) { + $scope = _$rootScope_.$new(); + $q = _$q_; + service = $injector.get('horizon.app.core.keypairs.actions.create.service'); + nova = $injector.get('horizon.app.core.openstack-service-api.nova'); + toast = $injector.get('horizon.framework.widgets.toast.service'); + deferredKeypairs = $q.defer(); + deferredKeypairs.resolve({data: {items: [{keypair: {name: "Hokusai"}}]}}); + spyOn(nova, 'getKeypairs').and.returnValue(deferredKeypairs.promise); + deferredNewKeypair = $q.defer(); + deferredNewKeypair.resolve({data: {items: [{keypair: {name: "Hiroshige"}}]}}); + spyOn(nova, 'createKeypair').and.returnValue(deferredNewKeypair.promise); + spyOn(modal, 'open').and.callThrough(); + spyOn(toast, 'add').and.callFake(angular.noop); + })); + + it('should check the policy if the user is allowed to create key pair', function() { + var allowed = service.allowed(); + expect(allowed).toBeTruthy(); + }); + + it('should open the modal and submit', inject(function() { + service.perform(); + expect(nova.getKeypairs).toHaveBeenCalled(); + expect(modal.open).toHaveBeenCalled(); + + $scope.$apply(); + expect(nova.createKeypair).toHaveBeenCalled(); + expect(toast.add).toHaveBeenCalled(); + })); + }); +})(); diff --git a/openstack_dashboard/static/app/core/keypairs/actions/import.description.html b/openstack_dashboard/static/app/core/keypairs/actions/import.description.html new file mode 100644 index 0000000000..cd114d7d32 --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/import.description.html @@ -0,0 +1,23 @@ +

+ Key Pairs are how you login to your instance after it is launched. + Choose a key pair name you will recognize and paste your SSH public key into the + space provided. +

+ +

+ There are two ways to generate a key pair. From a Linux system, + generate the key pair with the ssh-keygen command: +

+

+ ssh-keygen -t rsa -f cloud.key +

+

+ This command generates a pair of keys: a private key (cloud.key) + and a public key (cloud.key.pub). +

+

+ From a Windows system, you can use PuTTYGen to create private/public keys. + Use the PuTTY Key Generator to create and save the keys, then copy + the public key in the red highlighted box to your .ssh/authorized_keys + file. +

diff --git a/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.controller.js b/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.controller.js new file mode 100644 index 0000000000..71ef415dde --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.controller.js @@ -0,0 +1,43 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + /** + * @ngdoc controller + * @name horizon.app.core.keypairs.actions.ImportPublicKeyController + * @ngController + * + * @description + * Controller for the loading public key file + */ + angular + .module('horizon.app.core.keypairs.actions') + .controller('horizon.app.core.keypairs.actions.ImportPublicKeyController', + importPublicKeyController); + + importPublicKeyController.$inject = [ + '$scope' + ]; + + function importPublicKeyController($scope) { + var ctrl = this; + ctrl.title = $scope.schema.properties.public_key.title; + ctrl.public_key = ""; + ctrl.onPublicKeyChange = function (publicKey) { + $scope.model.public_key = publicKey; + }; + } +})(); diff --git a/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.controller.spec.js b/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.controller.spec.js new file mode 100644 index 0000000000..08e98ef4c2 --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.controller.spec.js @@ -0,0 +1,54 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + describe('horizon.app.core.keypairs.actions.ImportPublicKeyController', function() { + var ctrl, scope; + + beforeEach(module('horizon.app.core.keypairs')); + beforeEach(inject(function ($injector, _$rootScope_) { + scope = _$rootScope_.$new(); + scope.schema = { + properties: { + public_key: { + title: 'Public Key' + } + } + }; + scope.model = { + public_key: '' + }; + + var controller = $injector.get('$controller'); + ctrl = controller( + 'horizon.app.core.keypairs.actions.ImportPublicKeyController', + { $scope: scope } + ); + })); + + it('gets title from scope.schema.properties.public_key.title', function() { + expect(ctrl.title).toBe('Public Key'); + }); + + it('sets public key into scope.model.public_key', function() { + var key = 'public key string'; + ctrl.onPublicKeyChange(key); + expect(scope.model.public_key).toBeDefined(key); + }); + + }); + +})(); diff --git a/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.html b/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.html new file mode 100644 index 0000000000..5134c40222 --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/import.public-key.html @@ -0,0 +1,9 @@ +
+ + +
\ No newline at end of file diff --git a/openstack_dashboard/static/app/core/keypairs/actions/import.service.js b/openstack_dashboard/static/app/core/keypairs/actions/import.service.js new file mode 100644 index 0000000000..bece383bfa --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/import.service.js @@ -0,0 +1,157 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + /** + * @ngdoc overview + * @name horizon.app.core.keypairs.import.service + * @description Service for the key pair import modal + */ + angular + .module('horizon.app.core.keypairs.actions') + .factory('horizon.app.core.keypairs.actions.import.service', importService); + + importService.$inject = [ + 'horizon.app.core.keypairs.basePath', + 'horizon.app.core.keypairs.resourceType', + 'horizon.app.core.openstack-service-api.nova', + 'horizon.app.core.openstack-service-api.policy', + 'horizon.framework.util.actions.action-result.service', + 'horizon.framework.widgets.form.ModalFormService', + 'horizon.framework.widgets.toast.service' + ]; + + function importService( + basePath, resourceType, nova, policy, actionResult, modal, toast + ) { + + var keypairs = []; + var caption = gettext("Import Public Key"); + var invalidMsg = gettext("Key pair already exists."); + + // schema + var schema = { + type: "object", + properties: { + "name": { + title: gettext("Key Pair Name"), + type: "string", + pattern: "^[A-Za-z0-9 -]+$" + }, + "public_key": { + title: gettext("Public Key"), + type: "string" + } + } + }; + + // form + var form = [ + { + type: "section", + htmlClass: "row", + items: [ + { + type: "section", + htmlClass: "col-sm-6", + items: [ + { + key: "name", + validationMessage: { + keypairExists: invalidMsg + }, + $validators: { + keypairExists: function (name) { + return (keypairs.indexOf(name) === -1); + } + }, + required: true + }, + { + type: "template", + templateUrl: basePath + "actions/import.public-key.html" + } + ] + }, + { + type: "section", + htmlClass: "col-sm-6", + items: [ + { + type: "template", + templateUrl: basePath + "actions/import.description.html" + } + ] + } + ] + } + ]; + + // model + var model; + + var service = { + perform: perform, + allowed: allowed + }; + + return service; + + ////////////// + + function allowed() { + return policy.ifAllowed({ rules: [['compute', 'os_compute_api:os-keypairs:create']] }); + } + + function perform() { + getKeypairs(); + model = { + name: "", + public_key: "" + }; + var config = { + "title": caption, + "submitText": caption, + "schema": schema, + "form": form, + "model": model, + "submitIcon": "upload" + }; + return modal.open(config).then(submit); + } + + function submit(context) { + return nova.createKeypair(context.model).then(success); + } + + function success(response) { + var successMsg = gettext('Successfully imported key pair %(name)s.'); + toast.add('success', interpolate(successMsg, { name: response.data.name }, true)); + var result = actionResult.getActionResult().created(resourceType, response.data.name); + return result.result; + } + + function getKeypairs() { + nova.getKeypairs().then(function(response) { + keypairs = response.data.items.map(getName); + }); + } + + function getName(item) { + return item.keypair.name; + } + } +})(); diff --git a/openstack_dashboard/static/app/core/keypairs/actions/import.service.spec.js b/openstack_dashboard/static/app/core/keypairs/actions/import.service.spec.js new file mode 100644 index 0000000000..0af015375c --- /dev/null +++ b/openstack_dashboard/static/app/core/keypairs/actions/import.service.spec.js @@ -0,0 +1,75 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function() { + 'use strict'; + + describe('horizon.app.core.keypairs.actions.import.service', function() { + + var service, nova, $q, $scope, deferred, deferredKeypairs, deferredNewKeypair, toast; + var model = { + name: "Hiroshige", + public_key: "secret" + }; + var modal = { + open: function (config) { + config.model = model; + deferred = $q.defer(); + deferred.resolve(config); + return deferred.promise; + } + }; + + /////////////////// + + beforeEach(module('horizon.app.core')); + beforeEach(module('horizon.framework')); + beforeEach(module('horizon.app.core.keypairs.actions')); + + beforeEach(module(function($provide) { + $provide.value('horizon.framework.widgets.form.ModalFormService', modal); + })); + + beforeEach(inject(function($injector, _$rootScope_, _$q_) { + $scope = _$rootScope_.$new(); + $q = _$q_; + service = $injector.get('horizon.app.core.keypairs.actions.import.service'); + nova = $injector.get('horizon.app.core.openstack-service-api.nova'); + toast = $injector.get('horizon.framework.widgets.toast.service'); + deferredKeypairs = $q.defer(); + deferredKeypairs.resolve({data: {items: [{keypair: {name: "Hokusai"}}]}}); + spyOn(nova, 'getKeypairs').and.returnValue(deferredKeypairs.promise); + deferredNewKeypair = $q.defer(); + deferredNewKeypair.resolve({data: {items: [{keypair: {name: "Hiroshige"}}]}}); + spyOn(nova, 'createKeypair').and.returnValue(deferredNewKeypair.promise); + spyOn(modal, 'open').and.callThrough(); + spyOn(toast, 'add').and.callFake(angular.noop); + })); + + it('should check the policy if the user is allowed to import key pair', function() { + var allowed = service.allowed(); + expect(allowed).toBeTruthy(); + }); + + it('should open the modal and submit', inject(function() { + service.perform(); + expect(nova.getKeypairs).toHaveBeenCalled(); + expect(modal.open).toHaveBeenCalled(); + + $scope.$apply(); + expect(nova.createKeypair).toHaveBeenCalled(); + expect(toast.add).toHaveBeenCalled(); + })); + }); +})(); diff --git a/releasenotes/notes/bp-ng-keypairs-876c38a1a8aed60f.yaml b/releasenotes/notes/bp-ng-keypairs-876c38a1a8aed60f.yaml index 840bd7e7fd..d6372b01bc 100644 --- a/releasenotes/notes/bp-ng-keypairs-876c38a1a8aed60f.yaml +++ b/releasenotes/notes/bp-ng-keypairs-876c38a1a8aed60f.yaml @@ -2,7 +2,10 @@ features: - | [`blueprint ng-keypairs `_] - Add Angular Key Pairs panel. The Key Pairs panel allows users to view - a list of created or imported key pairs. This panel displays a table - view of the name and fingerprint of each key pair. Also, public key - is shown in expanded row. + AngularJS-based Key Pairs panel is added. The features in the legacy + panel are fully implemented. The Key Pairs panel now may be configured + to use either the legacy or AngularJS-based codes. + The ANGULAR_FEATURES setting now allows for a `key_pairs_panel`. + If set to True, then the AngularJS-Based Key Pairs panel will be used, + while the Django version will be used if set to False. Default value + for key_pairs_panel is True.