diff options
| author | Yoshie Muranaka <yoshiemuranaka@gmail.com> | 2019-07-17 11:23:15 -0500 |
|---|---|---|
| committer | Yoshie Muranaka <yoshiemuranaka@gmail.com> | 2019-08-13 11:37:28 -0500 |
| commit | fa56273db9ac556ca52db5d6d653b16eb63ca54e (patch) | |
| tree | f534fbdaf5136e9f0f54f331d32a1bf4d02329ce /app | |
| parent | 30d7c6377f70382088436c7a4830663eb522d588 (diff) | |
| download | phosphor-webui-fa56273db9ac556ca52db5d6d653b16eb63ca54e.tar.gz phosphor-webui-fa56273db9ac556ca52db5d6d653b16eb63ca54e.zip | |
Update local user table to new design
This commit will introduce a reusable data table component.
By creating a reusable component, we can ensure tables in the
GUI will look consistent and common table actions (sort, select row)
are shared.
- Created new components directory to store shared components
- Add password-confirmation directive
- Remove some error handling from API utils so it can be
handled in the UI
TODO:
- Add show/hide toggle to password fields
- Enhance table component with icons
- Manual user unlock
- Batch table actions
- Role table
Signed-off-by: Yoshie Muranaka <yoshiemuranaka@gmail.com>
Change-Id: I03c95874d2942a2450a5da2f1d2a8bb895aa1746
Diffstat (limited to 'app')
| -rw-r--r-- | app/common/components/index.js | 9 | ||||
| -rw-r--r-- | app/common/components/table/table.html | 36 | ||||
| -rw-r--r-- | app/common/components/table/table.js | 90 | ||||
| -rw-r--r-- | app/common/directives/password-confirmation.js | 42 | ||||
| -rw-r--r-- | app/common/services/api-utils.js | 31 | ||||
| -rw-r--r-- | app/common/styles/base/buttons.scss | 1 | ||||
| -rw-r--r-- | app/common/styles/base/forms.scss | 38 | ||||
| -rw-r--r-- | app/common/styles/base/typography.scss | 5 | ||||
| -rw-r--r-- | app/common/styles/base/utility.scss | 4 | ||||
| -rw-r--r-- | app/common/styles/components/table.scss | 22 | ||||
| -rw-r--r-- | app/common/styles/elements/modals.scss | 16 | ||||
| -rw-r--r-- | app/index.js | 7 | ||||
| -rw-r--r-- | app/users/controllers/user-accounts-controller.html | 148 | ||||
| -rw-r--r-- | app/users/controllers/user-accounts-controller.js | 521 | ||||
| -rw-r--r-- | app/users/controllers/user-accounts-modal-remove.html | 21 | ||||
| -rw-r--r-- | app/users/controllers/user-accounts-modal-settings.html | 85 | ||||
| -rw-r--r-- | app/users/controllers/user-accounts-modal-user.html | 149 | ||||
| -rw-r--r-- | app/users/directives/username-validator.js | 38 | ||||
| -rw-r--r-- | app/users/styles/user-accounts.scss | 94 |
19 files changed, 957 insertions, 400 deletions
diff --git a/app/common/components/index.js b/app/common/components/index.js new file mode 100644 index 0000000..67d5a2d --- /dev/null +++ b/app/common/components/index.js @@ -0,0 +1,9 @@ +/** + * A module to contain common components + */ +window.angular && (function(angular) { + 'use strict'; + + // Register app.common.components module + angular.module('app.common.components', []); +})(window.angular); diff --git a/app/common/components/table/table.html b/app/common/components/table/table.html new file mode 100644 index 0000000..6ec520c --- /dev/null +++ b/app/common/components/table/table.html @@ -0,0 +1,36 @@ +<table class="bmc-table"> + <thead> + <!-- Header row --> + <tr> + <th ng-repeat="header in $ctrl.model.header" + class="bmc-table__column-header"> + {{header}} + </th> + </tr> + </thead> + <tbody> + <!-- Data rows --> + <tr ng-if="$ctrl.model.data.length > 0" + ng-repeat="row in $ctrl.model.data" + class="bmc-table__row"> + <!-- Row item --> + <td ng-repeat="item in row.uiData" + class="bmc-table__cell"> + {{item}} + </td> + <!-- Row Actions --> + <td ng-if="$ctrl.model.actions.length > 0" + class="bmc-table__cell bmc-table__row-actions"> + <button ng-repeat="action in $ctrl.model.actions" + ng-click="$ctrl.onClickAction(action, row);" + class="btn btn-tertiary"> + {{action}} + </button> + </td> + </tr> + <!-- Empty table --> + <tr ng-if="$ctrl.model.data.length === 0"> + <td>No data</td> + </tr> + </tbody> +</table>
\ No newline at end of file diff --git a/app/common/components/table/table.js b/app/common/components/table/table.js new file mode 100644 index 0000000..2d7fc77 --- /dev/null +++ b/app/common/components/table/table.js @@ -0,0 +1,90 @@ +window.angular && (function(angular) { + 'use strict'; + + /** + * + * Controller for bmcTable Component + * + * To use: + * The <bmc-table> component expects a 'model' attribute + * that will contain all the data needed to render the table. + * + * The model object should contain 'header', 'data', and 'actions' + * properties. + * + * model: { + * header: <string>[], // Array of header labels + * data: <any>[], // Array of each row object + * actions: <string>[] // Array of action labels + * } + * + * The header property will render each label as a <th> in the table. + * + * The data property will render each item as a <tr> in the table. + * Each row object in the model.data array should also have a 'uiData' + * property that should be an array of the properties that will render + * as each table cell <td>. + * + * The actions property will render into clickable buttons at the end + * of each row. + * When a user clicks an action button, the component + * will emit the action label with the associated row object. + * + */ + const TableController = function() { + /** + * Init model data + * @param {any} model : table model object + * @returns : table model object with defaults + */ + const setModel = (model) => { + model.header = model.header === undefined ? [] : model.header; + model.data = model.data === undefined ? [] : model.data; + model.data = model.data.map((row) => { + if (row.uiData === undefined) { + row.uiData = []; + } + return row; + }) + model.actions = model.actions === undefined ? [] : model.actions; + + if (model.actions.length > 0) { + // If table actions were provided, push an empty + // string to the header array to account for additional + // table actions cell + model.header.push(''); + } + return model; + }; + + /** + * Callback when table row action clicked + * Emits user desired action and associated row data to + * parent controller + * @param {string} action : action type + * @param {any} row : user object + */ + this.onClickAction = (action, row) => { + if (action !== undefined && row !== undefined) { + const value = {action, row}; + this.emitAction({value}); + } + }; + + /** + * onInit Component lifecycle hooked + */ + this.$onInit = () => { + this.model = setModel(this.model); + }; + }; + + /** + * Register bmcTable component + */ + angular.module('app.common.components').component('bmcTable', { + template: require('./table.html'), + controller: TableController, + bindings: {model: '<', emitAction: '&'} + }) +})(window.angular); diff --git a/app/common/directives/password-confirmation.js b/app/common/directives/password-confirmation.js new file mode 100644 index 0000000..253a6a6 --- /dev/null +++ b/app/common/directives/password-confirmation.js @@ -0,0 +1,42 @@ +window.angular && (function(angular) { + 'use strict'; + + /** + * Password confirmation validator + * + * To use, add attribute directive to password confirmation input field + * Also include attribute 'first-password' with value set to first password + * to check against + * + * <input password-confirmation first-password="ctrl.password" + * name="passwordConfirm"> + * + */ + angular.module('app.common.directives') + .directive('passwordConfirm', function() { + return { + restrict: 'A', + require: 'ngModel', + scope: {firstPassword: '='}, + link: function(scope, element, attrs, controller) { + if (controller === undefined) { + return; + } + controller.$validators.passwordConfirm = + (modelValue, viewValue) => { + const firstPassword = + scope.firstPassword ? scope.firstPassword : ''; + const secondPassword = modelValue || viewValue || ''; + if (firstPassword == secondPassword) { + return true; + } else { + return false; + } + }; + element.on('keyup', () => { + controller.$validate(); + }); + } + }; + }); +})(window.angular); diff --git a/app/common/services/api-utils.js b/app/common/services/api-utils.js index d485016..27b122d 100644 --- a/app/common/services/api-utils.js +++ b/app/common/services/api-utils.js @@ -530,22 +530,17 @@ window.angular && (function(angular) { '/redfish/v1/AccountService/Roles', withCredentials: true }) - .then( - function(response) { - var members = response.data['Members']; - angular.forEach(members, function(member) { - roles.push(member['@odata.id'].split('/').pop()); - }); - return roles; - }, - function(error) { - console.log(error); - }); + .then(function(response) { + var members = response.data['Members']; + angular.forEach(members, function(member) { + roles.push(member['@odata.id'].split('/').pop()); + }); + return roles; + }); }, getAllUserAccounts: function() { var deferred = $q.defer(); var promises = []; - var users = []; $http({ method: 'GET', @@ -581,19 +576,15 @@ window.angular && (function(angular) { return deferred.promise; }, - getAllUserAccountProperties: function(callback) { + getAllUserAccountProperties: function() { return $http({ method: 'GET', url: DataService.getHost() + '/redfish/v1/AccountService', withCredentials: true }) - .then( - function(response) { - return response.data; - }, - function(error) { - console.log(error); - }); + .then(function(response) { + return response.data; + }); }, saveUserAccountProperties: function(lockoutduration, lockoutthreshold) { diff --git a/app/common/styles/base/buttons.scss b/app/common/styles/base/buttons.scss index 1d90036..25e5a91 100644 --- a/app/common/styles/base/buttons.scss +++ b/app/common/styles/base/buttons.scss @@ -53,6 +53,7 @@ button, display: inline-block; margin-right: 0.3em; vertical-align: bottom; + margin-left: -0.5em; } img { width: 1.5em; diff --git a/app/common/styles/base/forms.scss b/app/common/styles/base/forms.scss index f04e827..c775c48 100644 --- a/app/common/styles/base/forms.scss +++ b/app/common/styles/base/forms.scss @@ -1,8 +1,12 @@ label, legend { - font-size: 1em; - font-weight: 300; margin: 0; + color: $text-02; + text-transform: uppercase; + font-weight: 700; + font-size: 0.75em; + margin-bottom: 0; + line-height: 2.2; .error { font-size: 0.9em; } @@ -141,7 +145,21 @@ select { } .form__validation-message { color: $status-error; + font-size: 0.8em; + line-height: 1.1; + padding-top: 2px; +} + +.radio-label { + text-transform: none; + font-weight: normal; font-size: 0.9em; + line-height: 1.2; + margin: 0.8em 0; + color: $text-01; + input[type=radio] { + margin-bottom: 0; + } } /** @@ -210,3 +228,19 @@ select { margin-left: 1rem; } } + +.radio-option__input-field-group { + margin-left: 1.5em; +} + +.field-group-container { + margin-bottom: 30px; + position: relative; + &:last-child { + margin-bottom: 12px; + } + + input + .form__validation-message { + position: absolute; + } +} diff --git a/app/common/styles/base/typography.scss b/app/common/styles/base/typography.scss index baa6a60..bcf26d1 100644 --- a/app/common/styles/base/typography.scss +++ b/app/common/styles/base/typography.scss @@ -62,3 +62,8 @@ h5, font-weight: 700; margin-bottom: 0; } + +.page-title { + margin-bottom: 50px; + font-size: 2rem; +} diff --git a/app/common/styles/base/utility.scss b/app/common/styles/base/utility.scss index 26f138a..a271d33 100644 --- a/app/common/styles/base/utility.scss +++ b/app/common/styles/base/utility.scss @@ -130,4 +130,8 @@ @keyframes flash { 0% { background: $primary-accent; } 100% { background: none; } +} + +.nowrap { + white-space: nowrap!important; }
\ No newline at end of file diff --git a/app/common/styles/components/table.scss b/app/common/styles/components/table.scss index 67dc0be..17df264 100644 --- a/app/common/styles/components/table.scss +++ b/app/common/styles/components/table.scss @@ -146,3 +146,25 @@ } } } + +.bmc-table { + width: 100%; +} + +.bmc-table__row { + border-bottom: 1px solid $border-color-01; +} + +.bmc-table__column-header { + padding: 10px 16px; + background-color: $background-03; +} + +.bmc-table__cell { + padding: 4px 16px; + background-color: $base-02--07; +} + +.bmc-table__row-actions { + text-align: right; +}
\ No newline at end of file diff --git a/app/common/styles/elements/modals.scss b/app/common/styles/elements/modals.scss index dc1c9d8..0e21a39 100644 --- a/app/common/styles/elements/modals.scss +++ b/app/common/styles/elements/modals.scss @@ -97,9 +97,25 @@ } } +.uib-modal .modal-dialog { + // override bootstrap max-width set at 500px + max-width: 550px; +} + .modal-backdrop.in { opacity: 0.5; } .uib-modal__content { padding: 1em; } + +.uib-modal { + .btn--close { + position: absolute; + right: 0; + top: 0; + svg { + height: 2em; + } + } +}
\ No newline at end of file diff --git a/app/index.js b/app/index.js index a4d4bee..5525aef 100644 --- a/app/index.js +++ b/app/index.js @@ -62,6 +62,10 @@ import serial_console from './common/directives/serial-console.js'; import dir_paginate from './common/directives/dirPagination.js'; import form_input_error from './common/directives/form-input-error.js'; import icon_provider from './common/directives/icon-provider.js'; +import password_confirmation from './common/directives/password-confirmation.js'; + +import components_index from './common/components/index.js'; +import table_component from './common/components/table/table.js'; import login_index from './login/index.js'; import login_controller from './login/controllers/login-controller.js'; @@ -97,6 +101,7 @@ import vm_controller from './configuration/controllers/virtual-media-controller. import users_index from './users/index.js'; import user_accounts_controller from './users/controllers/user-accounts-controller.js'; +import username_validator from './users/directives/username-validator.js'; window.angular && (function(angular) { 'use strict'; @@ -111,7 +116,7 @@ window.angular && (function(angular) { 'ui.bootstrap', // Basic resources 'app.common.services', 'app.common.directives', - 'app.common.filters', + 'app.common.filters', 'app.common.components', // Model resources 'app.login', 'app.overview', 'app.serverControl', 'app.serverHealth', 'app.configuration', 'app.users', 'app.redfish' diff --git a/app/users/controllers/user-accounts-controller.html b/app/users/controllers/user-accounts-controller.html index 11cc85c..fd6a28c 100644 --- a/app/users/controllers/user-accounts-controller.html +++ b/app/users/controllers/user-accounts-controller.html @@ -1,129 +1,31 @@ <loader loading="loading"></loader> -<div id="user-accounts"> - - <div class="row column acnt-prop-header"> - <h1>User account properties</h1> - </div> - - <div class="col-sm-12"> - <form class="row column user-manage__form"> - <div class="col-sm-12"> - <label class="col-md-1 control-label" for="lockoutTime"> User Lockout Time (sec) </label> - <div class="col-md-3 acnt-prop__input-wrapper"> - <input type="number" id="lockoutTime" min="30" max="600" ng-model="properties.AccountLockoutDuration"/> - </div> - </div> - <div class="col-sm-12"> - <label class="col-md-1 control-label" for="lockoutThreshold"> Failed Login Attempts </label> - <div class="col-md-3 acnt-prop__input-wrapper"> - <input type="number" id="lockoutThreshold" min="3" max="10" ng-model="properties.AccountLockoutThreshold"/> - </div> - </div> - <div class="acnt-prop__submit-wrapper"> - <button type="button" class="btn btn-primary" ng-click="saveAllValues()">Save settings</button> - </div> - </form> - </div> - +<div class="local-users"> <div class="row column"> - <h1>User account information</h1> - </div> - <div class="table row column user-list__tbl" ng-show="users.length != 0"> - <div class="table__head"> - <div class="table__row"> - <div class="table__cell"> Username </div> - <div class="table__cell"> Enabled </div> - <div class="table__cell"> Role </div> - <div class="table__cell"> Locked </div> - <div class="table__cell"> Action </div> - </div> + <div class="column small-12"> + <h1 class="page-title">Local user management</h1> </div> - <div class="table__body"> - <div class="table__row" ng-repeat="user in users"> - <div class="table__cell"> {{user.UserName}} </div> - <div class="table__cell"> {{user.Enabled}} </div> - <div class="table__cell"> {{user.RoleId}} </div> - <div class="table__cell"> {{user.Locked}} </div> - <div class="table__cell"> - <button type="button" class="btn btn-primary" ng-disabled="isUserSelected" ng-click="setSelectedUser(user)">Edit</button> - <button type="button" class="btn btn-primary" ng-disabled="isUserSelected" ng-click="deleteUser(user.UserName)">Delete</button> - </div> + </div> + <div class="row column"> + <div class="column small-12"> + <div class="local-users__actions"> + <button ng-disabled="accountSettings === null" + ng-click="onClickAccountSettingsPolicy()" + class="btn btn-tertiary"> + <icon file="icon-config.svg"></icon> + Account policy settings + </button> + <button ng-disabled="userRoles === null || localUsers.length >= 15" + ng-click="onClickAddUser()" + class="btn btn-primary"> + <icon file="icon-plus.svg"></icon> + Add user + </button> </div> + <bmc-table + model="tableModel" + emit-action="onEmitAction(value)" + class="local-users__table"> + </bmc-table> </div> </div> - <div class="table row column" ng-show="users.length == 0"> - <span>No users exist in system</span> - </div> - - <form role="form" name="user__form" class="user-manage__form"> - <section class="row column" aria-label="user manage form" ng-class="{'submitted':submitted}"> - <div class="column small-12 page-header"> - <h2 class="inline">User account settings</h2> - </div> - <div class='col-sm-12'> - <label class="col-md-1 control-label" for="user-manage__username">UserName</label> - <div class="col-md-3"> - <input type="text" name="UserName" id="user-manage__username" ng-model="selectedUser.UserName" has-error="doesUserExist()" required /> - <div ng-messages="user__form.UserName.$error" class="form-error" ng-class="{'visible' : user__form.UserName.$touched || submitted}"> - <p ng-message="required">Field is required</p> - <p ng-message="hasError">Username exists</p> - </div> - </div> - </div> - <div class='col-sm-12 inline'> - <label class="col-md-1 control-label" for="user-manage__passwd">Password</label> - <div class="col-md-3 user-manage__input-wrapper inline"> - <input type="{{showpassword ? 'text' : 'password'}}" class="user-manage__new-password inline" name="Password" id="user-manage__passwd" ng-model="selectedUser.Password" ng-minlength="properties.MinPasswordLength" ng-maxlength="properties.MaxPasswordLength" required autocomplete="off"/> - <button ng-model="showpassword" ng-click="togglePassword = !togglePassword; showpassword = !showpassword;" class="btn btn-tertiary password-toggle"> - <span ng-hide="togglePassword">Show</span> - <span ng-show="togglePassword">Hide</span> - </button> - <div ng-messages="user__form.Password.$error" class="form-error" ng-class="{'visible' : user__form.Password.$touched || submitted}"> - <p ng-message="required">Field is required</p> - <p ng-message="minlength">Must be at least {{properties.MinPasswordLength}} characters</p> - <p ng-message="maxlength">Must be {{properties.MaxPasswordLength}} characters or less</p> - </div> - </div> - </div> - <div class='col-sm-12'> - <label class="col-md-1 control-label" for="user-manage__verifypasswd">Retype Password</label> - <div class="col-md-3 user-manage__input-wrapper inline"> - <input type="{{showpasswordVerify ? 'text' : 'password'}}" class="user-manage__verify-password inline" name="VerifyPassword" id="user-manage__verifypasswd" ng-model="selectedUser.VerifyPassword" has-error="selectedUser.VerifyPassword != selectedUser.Password" required autocomplete="off"> - <button ng-model="showpasswordVerify" ng-click="toggleVerify = !toggleVerify; showpasswordVerify = !showpasswordVerify;" class="btn btn-tertiary password-toggle"> - <span ng-hide="toggleVerify">Show</span> - <span ng-show="toggleVerify">Hide</span> - </button> - <div ng-messages="user__form.VerifyPassword.$error" class="form-error" ng-class="{'visible' : user__form.VerifyPassword.$touched || submitted}"> - <p ng-message="required">Field is required</p> - <p ng-message="hasError">Passwords do not match</p> - </div> - </div> - </div> - <div class='col-sm-12'> - <label class="col-md-1 control-label" for="role">Role</label> - <div class="col-md-3 user-manage__input-wrapper inline"> - <select ng-model="selectedUser.RoleId" id="role" name="role" class="inline" required> - <option ng-repeat="role in roles" class="inline">{{role}}</option> - </select> - <div ng-messages="user__form.role.$error" class="form-error" ng-class="{'visible' : user__form.role.$touched || submitted}"> - <p ng-message="required">Field is required</p> - </div> - </div> - </div> - <div class='col-sm-12'> - <label class="col-md-1 control-label" for="user-manage__enabled">Enabled</label> - <div class="col-md-3 user-manage__input-wrapper inline"> - <label class="control-check"> - <input type="checkbox" name="Enabled" id="user-manage__enabled" ng-model="selectedUser.Enabled"/> - <span class="control__indicator"></span> - </label> - </div> - </div> - <div class="user-manage__submit-wrapper"> - <button type="button" ng-click="submitted=true; user__form.$valid && createNewUser(); user__form.$setUntouched()" ng-show="!isUserSelected" class="btn btn-primary">Create user</button> - <button type="button" class="btn btn-primary" ng-click="submitted=true; user__form.$valid && updateUserInfo(); user__form.$setUntouched()" ng-show="isUserSelected">Save</button> - <button type="button" class="btn btn-primary" ng-if="isUserSelected" ng-click="cancel()">Cancel</button> - </div> - </section> - </form> -</div> +</div>
\ No newline at end of file diff --git a/app/users/controllers/user-accounts-controller.js b/app/users/controllers/user-accounts-controller.js index 12ec170..11ba13d 100644 --- a/app/users/controllers/user-accounts-controller.js +++ b/app/users/controllers/user-accounts-controller.js @@ -10,211 +10,362 @@ window.angular && (function(angular) { 'use strict'; angular.module('app.users').controller('userAccountsController', [ - '$scope', '$q', 'APIUtils', 'toastService', - function($scope, $q, APIUtils, toastService) { - $scope.users = []; - $scope.roles = []; - $scope.loading = true; - $scope.properties = {}; - $scope.origProp = {}; - $scope.submitted = false; - - function loadUserInfo() { - $scope.loading = true; - $scope.submitted = false; - $scope.isUserSelected = false; - $scope.selectedUser = {}; - $scope.togglePassword = false; - $scope.toggleVerify = false; - - $q.all([ - APIUtils.getAllUserAccounts().then( - function(res) { - $scope.users = res; - }, - function(error) { - console.log(JSON.stringify(error)); - }), - - APIUtils.getAllUserAccountProperties().then( - function(res) { - $scope.properties = res; - $scope.origProp = angular.copy($scope.properties); - }, - function(error) { - console.log(JSON.stringify(error)); - }), - - APIUtils.getAccountServiceRoles().then( - function(res) { - $scope.roles = res; - }, - function(error) { - console.log(JSON.stringify(error)); - }) - ]).finally(function() { - $scope.loading = false; - }); - }; + '$scope', 'APIUtils', 'toastService', '$uibModal', + function($scope, APIUtils, toastService, $uibModal) { + $scope.loading; + $scope.accountSettings; + $scope.userRoles; + $scope.localUsers; - $scope.cancel = function() { - loadUserInfo(); - }; + $scope.tableModel = {}; + $scope.tableModel.data = []; + $scope.tableModel.header = ['Username', 'Privilege', 'Account status'] + $scope.tableModel.actions = ['Edit', 'Delete']; - $scope.saveAllValues = function() { + /** + * Data table mapper + * @param {*} user + */ + function mapTableData(user) { + let accountStatus = + user.Locked ? 'Locked' : user.Enabled ? 'Enabled' : 'Disabled'; + user.uiData = [user.UserName, user.RoleId, accountStatus]; + return user; + } + + /** + * API call to get all user accounts + */ + function getLocalUsers() { $scope.loading = true; - var data = {}; - if ($scope.properties.AccountLockoutDuration != - $scope.origProp.AccountLockoutDuration) { - data['AccountLockoutDuration'] = - $scope.properties.AccountLockoutDuration; - } - if ($scope.properties.AccountLockoutThreshold != - $scope.origProp.AccountLockoutThreshold) { - data['AccountLockoutThreshold'] = - $scope.properties.AccountLockoutThreshold; - } + APIUtils.getAllUserAccounts() + .then((users) => { + $scope.localUsers = users; + $scope.tableModel.data = users.map(mapTableData); + }) + .catch((error) => { + console.log(JSON.stringify(error)); + toastService.error('Failed to load users.'); + }) + .finally(() => { + $scope.loading = false; + }) + } - if ($scope.properties.AccountLockoutDuration == - $scope.origProp.AccountLockoutDuration && - $scope.properties.AccountLockoutThreshold == - $scope.origProp.AccountLockoutThreshold) { - // No change in properties, just return; - $scope.loading = false; - return; - } + /** + * API call to get current Account settings + */ + function getAccountSettings() { + APIUtils.getAllUserAccountProperties() + .then((settings) => { + $scope.accountSettings = settings; + }) + .catch((error) => { + console.log(JSON.stringify(error)); + $scope.accountSettings = null; + }) + } - APIUtils - .saveUserAccountProperties( - data['AccountLockoutDuration'], data['AccountLockoutThreshold']) - .then( - function(response) { - toastService.success( - 'User account properties have been updated successfully'); - }, - function(error) { - toastService.error('Unable to update account properties'); - }) - .finally(function() { - loadUserInfo(); + /** + * API call to get local user roles + */ + function getUserRoles() { + APIUtils.getAccountServiceRoles() + .then((roles) => { + $scope.userRoles = roles; + }) + .catch((error) => { + console.log(JSON.stringify(error)); + $scope.userRoles = null; + }) + } + + /** + * API call to create new user + * @param {*} user + */ + function createUser(username, password, role, enabled) { + $scope.loading = true; + APIUtils.createUser(username, password, role, enabled) + .then(() => { + getLocalUsers(); + toastService.success(`User '${username}' has been created.`); + }) + .catch((error) => { + console.log(JSON.stringify(error)); + toastService.error(`Failed to create new user '${username}'.`); + }) + .finally(() => { $scope.loading = false; }); - }; - - $scope.setSelectedUser = function(user) { - $scope.isUserSelected = true; - $scope.selectedUser = angular.copy(user); - $scope.selectedUser.VerifyPassword = null; - // Used while renaming the user. - $scope.selectedUser.CurrentUserName = $scope.selectedUser.UserName; - }; - $scope.createNewUser = function() { - if ($scope.users.length >= 15) { - toastService.error( - 'Cannot create user. The maximum number of users that can be created is 15'); - return; - } - if (!$scope.selectedUser.UserName || !$scope.selectedUser.Password) { - toastService.error('Username or password cannot be empty'); - return; - } - if ($scope.selectedUser.Password !== - $scope.selectedUser.VerifyPassword) { - toastService.error('Passwords do not match'); - return; - } - if ($scope.doesUserExist()) { - toastService.error('Username already exists'); - return; - } - var user = $scope.selectedUser.UserName; - var passwd = $scope.selectedUser.Password; - var role = $scope.selectedUser.RoleId; - var enabled = false; - if ($scope.selectedUser.Enabled != null) { - enabled = $scope.selectedUser.Enabled; - } + } + /** + * API call to update existing user + */ + function updateUser(originalUsername, username, password, role, enabled) { $scope.loading = true; - APIUtils.createUser(user, passwd, role, enabled) - .then( - function(response) { - toastService.success('User has been created successfully'); - }, - function(error) { - toastService.error('Failed to create new user'); - }) - .finally(function() { - loadUserInfo(); + APIUtils.updateUser(originalUsername, username, password, role, enabled) + .then(() => { + getLocalUsers(); + toastService.success('User has been updated successfully.') + }) + .catch((error) => { + console.log(JSON.stringify(error)); + toastService.error(`Unable to update user '${originalUsername}'.`) + }) + .finally(() => { $scope.loading = false; - }); - }; - $scope.updateUserInfo = function() { - if ($scope.selectedUser.Password !== - $scope.selectedUser.VerifyPassword) { - toastService.error('Passwords do not match'); - return; - } - if ($scope.doesUserExist()) { - toastService.error('Username already exists'); - return; - } - var data = {}; - if ($scope.selectedUser.UserName !== - $scope.selectedUser.CurrentUserName) { - data['UserName'] = $scope.selectedUser.UserName; - } - $scope.selectedUser.VerifyPassword = null; - if ($scope.selectedUser.Password != null) { - data['Password'] = $scope.selectedUser.Password; - } - data['RoleId'] = $scope.selectedUser.RoleId; - data['Enabled'] = $scope.selectedUser.Enabled; + }) + } + /** + * API call to delete user + * @param {*} username + */ + function deleteUser(username) { $scope.loading = true; - APIUtils - .updateUser( - $scope.selectedUser.CurrentUserName, data['UserName'], - data['Password'], data['RoleId'], data['Enabled']) - .then( - function(response) { - toastService.success('User has been updated successfully'); - }, - function(error) { - toastService.error('Unable to update user'); - }) - .finally(function() { - loadUserInfo(); + APIUtils.deleteUser(username) + .then(() => { + getLocalUsers(); + toastService.success(`User '${username}' has been deleted.`); + }) + .catch((error) => { + console.log(JSON.stringify(error)); + toastService.error(`Failed to delete user '${username}'.`); + }) + .finally(() => { $scope.loading = false; }); - }; - $scope.deleteUser = function(userName) { + } + + /** + * API call to save account policy settings + * @param {number} lockoutDuration + * @param {number} lockoutThreshold + */ + function updateAccountSettings(lockoutDuration, lockoutThreshold) { $scope.loading = true; - APIUtils.deleteUser(userName) - .then( - function(response) { - toastService.success('User has been deleted successfully'); - }, - function(error) { - toastService.error('Unable to delete user'); - }) - .finally(function() { - loadUserInfo(); + APIUtils.saveUserAccountProperties(lockoutDuration, lockoutThreshold) + .then(() => { + $scope.accountSettings['AccountLockoutDuration'] = + lockoutDuration; + $scope.accountSettings['AccountLockoutThreshold'] = + lockoutThreshold; + toastService.success( + 'Account policy settings have been updated.'); + }) + .catch((error) => { + console.log(JSON.stringify(error)); + toastService.error('Failed to update account policy settings.'); + }) + .finally(() => { $scope.loading = false; }); - }; + } - $scope.doesUserExist = function() { - for (var i in $scope.users) { - // If a user exists with the same user name and a different Id then - // the username already exists and isn't valid - if (($scope.users[i].UserName === $scope.selectedUser.UserName) && - ($scope.users[i].Id !== $scope.selectedUser.Id)) { - return true; - } + /** + * Initiate account settings modal + */ + function initAccountSettingsModal() { + const template = require('./user-accounts-modal-settings.html'); + $uibModal + .open({ + template, + windowTopClass: 'uib-modal', + ariaLabelledBy: 'dialog_label', + controllerAs: 'modalCtrl', + controller: function() { + // If AccountLockoutDuration is not 0 the lockout + // method is automatic. If AccountLockoutDuration is 0 the + // lockout method is manual + const lockoutMethod = + $scope.accountSettings.AccountLockoutDuration ? 1 : 0; + this.settings = {}; + this.settings.maxLogin = + $scope.accountSettings.AccountLockoutThreshold; + this.settings.lockoutMethod = lockoutMethod; + this.settings.timeoutDuration = !lockoutMethod ? + null : + $scope.accountSettings.AccountLockoutDuration; + } + }) + .result + .then((form) => { + if (form.$valid) { + const lockoutDuration = form.lockoutMethod.$modelValue ? + form.timeoutDuration.$modelValue : + 0; + const lockoutThreshold = form.maxLogin.$modelValue; + updateAccountSettings(lockoutDuration, lockoutThreshold); + } + }) + .catch( + () => { + // do nothing + }) + } + + /** + * Initiate user modal + * Can be triggered by clicking edit in table or 'Add user' button + * If triggered from the table, user parameter will be provided + * If triggered by add user button, user parameter will be undefined + * @optional @param {*} user + */ + function initUserModal(user) { + if ($scope.userRoles === null || $scope.userRoles === undefined) { + // If userRoles failed to load, do not allow add/edit + // functionality + return; } + const newUser = user ? false : true; + const originalUsername = user ? angular.copy(user.UserName) : null; + const template = require('./user-accounts-modal-user.html'); + $uibModal + .open({ + template, + windowTopClass: 'uib-modal', + ariaLabelledBy: 'dialog_label', + controllerAs: 'modalCtrl', + controller: function() { + // Set default status to Enabled + const status = newUser ? true : user.Enabled; + // Check if UserName is root + // Some form controls will be disabled for root users: + // edit enabled status, edit username, edit role + const isRoot = + newUser ? false : user.UserName === 'root' ? true : false; + // Array of existing usernames (excluding current user instance) + const existingUsernames = + $scope.localUsers.reduce((acc, val) => { + if (user && (val.UserName === user.UserName)) { + return acc; + } + acc.push(val.UserName); + return acc; + }, []); + + this.user = {}; + this.user.isRoot = isRoot; + this.user.new = newUser; + this.user.accountStatus = status; + this.user.username = newUser ? '' : user.UserName; + this.user.privilege = newUser ? '' : user.RoleId; + + this.privilegeRoles = $scope.userRoles; + this.existingUsernames = existingUsernames; + this.minPasswordLength = $scope.accountSettings ? + $scope.accountSettings.MinPasswordLength : + null; + this.maxPasswordLength = $scope.accountSettings ? + $scope.accountSettings.MaxPasswordLength : + null; + } + }) + .result + .then((form) => { + if (form.$valid) { + // If form control is pristine set property to null + // this will make sure only changed values are updated when + // modifying existing users + // API utils checks for null values + const username = + form.username.$pristine ? null : form.username.$modelValue; + const password = + form.password.$pristine ? null : form.password.$modelValue; + const role = form.privilege.$pristine ? + null : + form.privilege.$modelValue; + const enabled = (form.accountStatus.$pristine && + form.accountStatus1.$pristine) ? + null : + form.accountStatus.$modelValue; + + if (!newUser) { + updateUser( + originalUsername, username, password, role, enabled); + } else { + createUser( + username, password, role, form.accountStatus.$modelValue); + } + } + }) + .catch( + () => { + // do nothing + }) + } + + /** + * Intiate remove user modal + * @param {*} user + */ + function initRemoveModal(user) { + const template = require('./user-accounts-modal-remove.html'); + $uibModal + .open({ + template, + windowTopClass: 'uib-modal', + ariaLabelledBy: 'dialog_label', + controllerAs: 'modalCtrl', + controller: function() { + this.user = user.UserName; + } + }) + .result + .then(() => { + const isRoot = user.UserName === 'root' ? true : false; + if (isRoot) { + toastService.error(`Cannot delete 'root' user.`) + return; + } + deleteUser(user.UserName); + }) + .catch( + () => { + // do nothing + }) + } + + /** + * Callback when action emitted from table + * @param {*} value + */ + $scope.onEmitAction = (value) => { + switch (value.action) { + case 'Edit': + initUserModal(value.row); + break; + case 'Delete': + initRemoveModal(value.row); + break; + default: + } + }; + + /** + * Callback when 'Account settings policy' button clicked + */ + $scope.onClickAccountSettingsPolicy = () => { + initAccountSettingsModal(); }; - loadUserInfo(); + + /** + * Callback when 'Add user' button clicked + */ + $scope.onClickAddUser = () => { + initUserModal(); + }; + + /** + * Callback when controller view initially loaded + */ + $scope.$on('$viewContentLoaded', () => { + getLocalUsers(); + getUserRoles(); + getAccountSettings(); + }) } ]); })(angular); diff --git a/app/users/controllers/user-accounts-modal-remove.html b/app/users/controllers/user-accounts-modal-remove.html new file mode 100644 index 0000000..e615251 --- /dev/null +++ b/app/users/controllers/user-accounts-modal-remove.html @@ -0,0 +1,21 @@ +<div class="uib-modal__content modal__local-users-remove"> + <div class="modal-header"> + <h2 class="modal-title" id="dialog_label"> + Remove user + </h2> + <button type="button" class="btn btn--close float-right" ng-click="$dismiss()" aria-label="Close"> + <icon file="icon-close.svg" aria-hidden="true"></icon> + </button> + </div> + <div class="modal-body"> + <p>Are you sure you want to remove user '{{modalCtrl.user}}'? This action cannot be undone.</p> + </div> + <div class="modal-footer"> + <button class="btn btn-secondary" ng-click="$dismiss()" type="button"> + Cancel + </button> + <button class="btn btn-primary" ng-click="$close()" type="button"> + Remove + </button> + </div> +</div> diff --git a/app/users/controllers/user-accounts-modal-settings.html b/app/users/controllers/user-accounts-modal-settings.html new file mode 100644 index 0000000..d48809f --- /dev/null +++ b/app/users/controllers/user-accounts-modal-settings.html @@ -0,0 +1,85 @@ +<div class="uib-modal__content modal__local-users-settings"> + <div class="modal-header"> + <h2 class="modal-title" id="dialog_label">Account policy settings</h2> + <button type="button" class="btn btn--close float-right" ng-click="$dismiss()" aria-label="Close"> + <icon file="icon-close.svg" aria-hidden="true"></icon> + </button> + </div> + <form name="form"> + <div class="modal-body"> + <div class="row"> + <div class="column medium-6"> + <!-- Max login attempts --> + <div class="field-group-container"> + <label for="maxLogin">Max failed login attempts</label> + <p class="label__helper-text">Value must be between <span class="nowrap">0 – 65535</span></p> + <input id="maxLogin" + name="maxLogin" + type="number" + required + min="0" + max="65535" + ng-model="modalCtrl.settings.maxLogin" /> + <div ng-if="form.maxLogin.$invalid && form.maxLogin.$dirty" class="form__validation-message"> + <span ng-show="form.maxLogin.$error.required"> + Field is required</span> + <span ng-show="form.maxLogin.$error.min || form.maxLogin.$error.max"> + Value must be between <span class="nowrap">1 - 65535</span></span> + </div> + </div> + </div> + <div class="column medium-6"> + <!-- User unlock method --> + <fieldset class="field-group-container"> + <legend>User unlock method</legend> + <!-- Automatic radio option --> + <label class="radio-label"> + <input name="lockoutMethod" + type="radio" + ng-value="1" + ng-model="modalCtrl.settings.lockoutMethod"> + Automatic after timeout + </label> + <!-- Automatic timeout value --> + <div class="field-group-container radio-option__input-field-group"> + <label for="lockoutMethod1">Timeout duration (seconds)</label> + <p class="label__helper-text" id="lockoutMethod1Helper">Must be at least 1</p> + <input id="lockoutMethod1" + name="timeoutDuration" + type="number" + aria-describedby="lockoutMethod1Helper" + ng-min="modalCtrl.settings.lockoutMethod ? 1 : null" + ng-disabled="!modalCtrl.settings.lockoutMethod" + ng-required="modalCtrl.settings.lockoutMethod" + ng-model="modalCtrl.settings.timeoutDuration"/> + <div ng-if="form.timeoutDuration.$invalid && form.timeoutDuration.$touched" class="form__validation-message"> + <span ng-show="form.timeoutDuration.$error.required"> + Field is required</span> + <span ng-show="form.timeoutDuration.$error.min"> + Value must be at least 1</span> + </div> + </div> + <!-- Manual radio option --> + <label class="radio-label"> + <input name="lockoutMethod" + type="radio" + ng-value="0" + ng-model="modalCtrl.settings.lockoutMethod"> + Manual + </label> + </fieldset> + </div> + </div> + </div> + <div class="modal-footer"> + <button class="btn btn-secondary" ng-click="$dismiss()" type="button">Cancel</button> + <button class="btn btn-primary" + type="submit" + ng-click="$close(form)" + ng-disabled="form.$invalid || form.$pristine" + ng-class="{'disabled': form.$invalid}"> + Save + </button> + </div> + </form> +</div> diff --git a/app/users/controllers/user-accounts-modal-user.html b/app/users/controllers/user-accounts-modal-user.html new file mode 100644 index 0000000..7df5ea1 --- /dev/null +++ b/app/users/controllers/user-accounts-modal-user.html @@ -0,0 +1,149 @@ +<div class="uib-modal__content modal__local-users"> + <div class="modal-header"> + <h2 class="modal-title" id="dialog_label"> + {{ modalCtrl.user.new ? 'Add user' : 'Modify user' }} + </h2> + <button type="button" class="btn btn--close" ng-click="$dismiss()" aria-label="Close"> + <icon file="icon-close.svg" aria-hidden="true"></icon> + </button> + </div> + <form name="form"> + <div class="modal-body"> + <div class="row"> + <div class="column medium-6"> + <!-- Account Status --> + <fieldset class="field-group-container"> + <legend>Account Status</legend> + <label class="radio-label"> + <input type="radio" + name="accountStatus" + ng-value="true" + ng-model="modalCtrl.user.accountStatus" + ng-disabled="modalCtrl.user.isRoot"> + Enabled + </label> + <label class="radio-label"> + <input type="radio" + name="accountStatus1" + ng-value="false" + ng-model="modalCtrl.user.accountStatus" + ng-disabled="modalCtrl.user.isRoot"> + Disabled + </label> + </fieldset> + <!-- Username --> + <div class="field-group-container"> + <label for="username">Username</label> + <p class="label__helper-text">Cannot start with a number</p> + <p class="label__helper-text">No special characters except underscore</p> + <input id="username" + name="username" + type="text" + required + minlength="1" + maxlength="16" + ng-pattern="'^([a-zA-Z_][a-zA-Z0-9_]*)'" + ng-readonly="modalCtrl.user.isRoot" + ng-model="modalCtrl.user.username" + username-validator + existing-usernames="modalCtrl.existingUsernames"/> + <div ng-if="form.username.$invalid && form.username.$touched" class="form__validation-message"> + <span ng-show="form.username.$error.required"> + Field is required</span> + <span ng-show="form.username.$error.minlength || form.username.$error.maxlength"> + Length must be between <span class="nowrap">1 – 16</span> characters</span> + <span ng-show="form.username.$error.pattern"> + Invalid format</span> + <span ng-show="form.username.$error.duplicateUsername"> + Username already exists</span> + </div> + </div> + <!-- Privlege --> + <div class="field-group-container"> + <label for="privilege">Privilege</label> + <select id="privilege" + name="privilege" + required + ng-disabled="modalCtrl.user.isRoot" + ng-model="modalCtrl.user.privilege"> + <option ng-if="modalCtrl.user.new" + ng-selected="modalCtrl.user.new" + value="" + disabled> + Select an option + </option> + <option ng-value="role" + ng-repeat="role in modalCtrl.privilegeRoles"> + {{role}} + </option> + </select> + </div> + </div> + <div class="column medium-6"> + <!-- Password --> + <div class="field-group-container"> + <label for="password">User password</label> + <p class="label__helper-text">Password must between <span class="nowrap">{{modalCtrl.minPasswordLength}} – {{modalCtrl.maxPasswordLength}}</span> characters</p> + <input id="password" + name="password" + type="password" + ng-minlength="modalCtrl.minPasswordLength" + ng-maxlength="modalCtrl.maxPasswordLength" + autocomplete="new-password" + ng-required="modalCtrl.user.new || form.password.$touched || form.passwordConfirm.$touched" + ng-model="modalCtrl.user.password" + ng-click="form.password.$setTouched()" + placeholder="{{ + (modalCtrl.user.new || + form.password.$touched || + form.passwordConfirm.$touched) ? '' : '******'}}"/> + <div ng-if="form.password.$invalid && form.password.$dirty" class="form__validation-message"> + <span ng-show="form.password.$error.required"> + Field is required</span> + <span ng-show="form.password.$error.minlength || form.password.$error.maxlength"> + Length must be between <span class="nowrap">{{modalCtrl.minPasswordLength}} – {{modalCtrl.maxPasswordLength}}</span> characters</span> + </div> + </div> + <!-- Password confirm --> + <div class="field-group-container"> + <label for="passwordConfirm">Confirm user password</label> + <input id="passwordConfirm" + name="passwordConfirm" + type="password" + autocomplete="new-password" + ng-required="modalCtrl.user.new || form.password.$touched || form.passwordConfirm.$touched" + ng-model="modalCtrl.user.passwordConfirm" + password-confirm + first-password="form.password.$modelValue" + ng-click="form.passwordConfirm.$setTouched()" + placeholder="{{( + modalCtrl.user.new || + form.password.$touched || + form.passwordConfirm.$touched) ? '' : '******'}}"/> + <div ng-if="form.passwordConfirm.$invalid && form.passwordConfirm.$dirty" class="form__validation-message"> + <span ng-show="form.passwordConfirm.$error.required"> + Field is required</span> + <span ng-show="form.passwordConfirm.$error.passwordConfirm" + ng-hide="form.passwordConfirm.$error.required"> + Passwords do not match</span> + <span ng-show="form.passwordConfirm.$error.minlength || form.passwordConfirm.$error.maxlength"> + Length must be between <span class="nowrap">1 – 16</span> characters</span> + </div> + </div> + </div> + </div> + </div> + <div class="modal-footer"> + <button class="btn btn-secondary" ng-click="$dismiss()" type="button"> + Cancel + </button> + <button class="btn btn-primary" + type="submit" + ng-click="$close(form)" + ng-disabled="form.$invalid || form.$pristine" + ng-class="{'disabled': form.$invalid}"> + {{ modalCtrl.user.new ? 'Add user' : 'Save' }} + </button> + </div> + </form> +</div> diff --git a/app/users/directives/username-validator.js b/app/users/directives/username-validator.js new file mode 100644 index 0000000..d8c5848 --- /dev/null +++ b/app/users/directives/username-validator.js @@ -0,0 +1,38 @@ +window.angular && (function(angular) { + 'use strict'; + + /** + * Username validator + * + * Checks if entered username is a duplicate + * Provide existingUsernames scope that should be an array of + * existing usernames + * + * <input username-validator existing-usernames="[]"/> + * + */ + angular.module('app.users').directive('usernameValidator', function() { + return { + restrict: 'A', require: 'ngModel', scope: {existingUsernames: '='}, + link: function(scope, element, attrs, controller) { + if (scope.existingUsernames === undefined) { + return; + } + controller.$validators.duplicateUsername = + (modelValue, viewValue) => { + const enteredUsername = modelValue || viewValue; + const matchedExisting = scope.existingUsernames.find( + (username) => username === enteredUsername); + if (matchedExisting) { + return false; + } else { + return true; + } + }; + element.on('blur', () => { + controller.$validate(); + }); + } + } + }); +})(window.angular); diff --git a/app/users/styles/user-accounts.scss b/app/users/styles/user-accounts.scss index a91bca6..9658b90 100644 --- a/app/users/styles/user-accounts.scss +++ b/app/users/styles/user-accounts.scss @@ -1,75 +1,31 @@ -.acnt-prop-header { - width: 100%; - border-bottom: 2px solid $border-color-01; - margin: 0px 0px 15px; +.local-users__actions { + display: flex; + flex-direction: row; + justify-content: flex-end; } -.user-manage__form { - width: 100%; - .dropdown__button { - margin-bottom: 1.2em; - } - label { - width: 100%; - min-width: 210px; - font-weight: 700; - margin-right: 4em; - } - select, - input { - width: 225px; - width: 225px; - } - fieldset { - display: block; - padding-left: 1.5em; - margin-bottom: 1em; - border-bottom: 1px solid $border-color-01; + +.local-users__actions, +.local-users__table .bmc-table { + max-width: 900px; +} + +.modal__local-users, +.modal__local-users-settings { + .modal-body { + padding-left: 0; + padding-right: 0; } - .acnt-prop__input-wrapper, - .user-manage__input-wrapper { - margin-bottom: 5px; - select { - margin-bottom: 0; +} + +.modal__local-users { + input[type="password"] { + &::placeholder { + color: $primary-dark; + font-weight: bold; } - } - .acnt-prop__span-wrapper { - position: relative; - height: 20px; - margin-bottom: 5px; - } - .password-toggle { - position: absolute; - right: 20px; - top: .6em; - padding: 3px; - font-size: .8em; - } - .acnt-prop__submit-wrapper, - .user-manage__submit-wrapper { - width: 100%; - margin-top: 6px; - padding-top: 1px; - border-top: 1px solid $border-color-01; - button { - float: right; - margin: .5em; + &::-ms-placeholder { + color: $primary-dark; + font-weight: bold; } } - .user-manage__error { - background: lighten($status-error, 20%); - padding: 1em; - text-align: center; - font-size: 1em; - border: 1px solid $status-error; - color: $primary-dark; - font-family: "Courier New", Helvetica, Arial, sans-serif; - font-weight: 700; - } - .user-manage__success { - color: $primary-accent; - padding: 1em; - font-size: 1em; - font-family: "Courier New", Helvetica, Arial, sans-serif; - font-weight: 500; - } } |

