Update local user layout and styles

Resubmitting after reverted–original commit here
https://gerrit.openbmc-project.xyz/c/openbmc/webui-vue/+/28790

- Add BVConfig plugin to modify boostrap component
defaults
- Add vuelidate
- Add package and basic validations to user form
- Add all user form validations
- Add checks for edit user
- Create VuelidateMixin for shared methods
- Update Login to use Vuelidate

Signed-off-by: Yoshie Muranaka <yoshiemuranaka@gmail.com>
Signed-off-by: Derick Montague <derick.montague@ibm.com>
Change-Id: Ib50ee4d1fb5f14637c9460e77f0682869a86ac8a
diff --git a/src/assets/styles/_form-components.scss b/src/assets/styles/_form-components.scss
new file mode 100644
index 0000000..41b291b
--- /dev/null
+++ b/src/assets/styles/_form-components.scss
@@ -0,0 +1,29 @@
+.form-text {
+  margin-top: -$spacer / 4;
+  margin-bottom: $spacer / 2;
+  color: $gray-800;
+}
+
+.col-form-label {
+  color: $gray-800;
+  font-size: 14px;
+}
+
+.form-group {
+  margin-bottom: $spacer * 2;
+}
+
+.custom-select,
+.custom-control-label,
+.form-control {
+  //important needed to override validation colors on radio labels
+  color: $gray-900!important;
+  border-color: $gray-400!important;
+  &::before {
+    border-color: $primary;
+  }
+  &.is-invalid,
+  &:invalid {
+    border-bottom: 2px solid $danger!important;
+  }
+}
\ No newline at end of file
diff --git a/src/assets/styles/_modal.scss b/src/assets/styles/_modal.scss
new file mode 100644
index 0000000..b20327e
--- /dev/null
+++ b/src/assets/styles/_modal.scss
@@ -0,0 +1,7 @@
+.modal-header {
+  .close {
+    font-weight: normal;
+    color: $gray-900;
+    opacity: 1;
+  }
+}
\ No newline at end of file
diff --git a/src/assets/styles/_obmc-custom.scss b/src/assets/styles/_obmc-custom.scss
index e87e01b..d20e64e 100644
--- a/src/assets/styles/_obmc-custom.scss
+++ b/src/assets/styles/_obmc-custom.scss
@@ -1,4 +1,5 @@
 $enable-rounded: false;
+$enable-validation-icons: false;
 
 // Required
 @import "~bootstrap/scss/functions";
@@ -52,4 +53,7 @@
 
 @import "~bootstrap-vue/src/index.scss";
 
-@import "./buttons";
\ No newline at end of file
+@import "./buttons";
+@import "./form-components";
+@import "./modal";
+@import "./table";
\ No newline at end of file
diff --git a/src/assets/styles/_table.scss b/src/assets/styles/_table.scss
new file mode 100644
index 0000000..ff1ed30
--- /dev/null
+++ b/src/assets/styles/_table.scss
@@ -0,0 +1,21 @@
+.table-light {
+  td {
+    border-top: none;
+    border-bottom: 1px solid $gray-300;
+  }
+}
+
+.thead-light.thead-light {
+  th {
+    border: none;
+    color: $gray-900;
+  }
+}
+
+.table-cell__actions {
+  text-align: right;
+  .btn {
+    padding-top: 0;
+    padding-bottom: 0;
+  }
+}
diff --git a/src/components/Mixins/VuelidateMixin.js b/src/components/Mixins/VuelidateMixin.js
new file mode 100644
index 0000000..8c61779
--- /dev/null
+++ b/src/components/Mixins/VuelidateMixin.js
@@ -0,0 +1,10 @@
+const VuelidateMixin = {
+  methods: {
+    getValidationState(model) {
+      const { $dirty, $error } = model;
+      return $dirty ? !$error : null;
+    }
+  }
+};
+
+export default VuelidateMixin;
diff --git a/src/main.js b/src/main.js
index b69c659..e32a56b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -7,6 +7,7 @@
   AlertPlugin,
   BadgePlugin,
   ButtonPlugin,
+  BVConfigPlugin,
   CollapsePlugin,
   FormPlugin,
   FormCheckboxPlugin,
@@ -22,12 +23,20 @@
   NavPlugin,
   TablePlugin
 } from 'bootstrap-vue';
+import Vuelidate from 'vuelidate';
 
 Vue.filter('date', dateFilter);
 
 Vue.use(AlertPlugin);
 Vue.use(BadgePlugin);
 Vue.use(ButtonPlugin);
+Vue.use(BVConfigPlugin, {
+  BFormText: { textVariant: 'black' },
+  BTable: {
+    headVariant: 'light',
+    footVariant: 'light'
+  }
+});
 Vue.use(CollapsePlugin);
 Vue.use(FormPlugin);
 Vue.use(FormCheckboxPlugin);
@@ -43,6 +52,7 @@
 Vue.use(NavbarPlugin);
 Vue.use(NavPlugin);
 Vue.use(TablePlugin);
+Vue.use(Vuelidate);
 
 new Vue({
   router,
diff --git a/src/store/modules/Authentication/AuthenticanStore.js b/src/store/modules/Authentication/AuthenticanStore.js
index 3a554b6..8d8898e 100644
--- a/src/store/modules/Authentication/AuthenticanStore.js
+++ b/src/store/modules/Authentication/AuthenticanStore.js
@@ -4,35 +4,28 @@
 const AuthenticationStore = {
   namespaced: true,
   state: {
-    status: '',
+    authError: false,
     cookie: Cookies.get('XSRF-TOKEN')
   },
   getters: {
-    authStatus: state => state.status,
+    authError: state => state.authError,
     isLoggedIn: state => !!state.cookie
   },
   mutations: {
-    authRequest(state) {
-      state.status = 'processing';
-    },
     authSuccess(state) {
-      state.status = 'authenticated';
+      state.authError = false;
       state.cookie = Cookies.get('XSRF-TOKEN');
     },
     authError(state) {
-      state.status = 'error';
-    },
-    authReset(state) {
-      state.status = '';
+      state.authError = true;
     },
     logout(state) {
-      state.status = '';
+      state.authError = false;
       Cookies.remove('XSRF-TOKEN');
     }
   },
   actions: {
     login({ commit }, auth) {
-      commit('authRequest');
       return api
         .post('/login', { data: auth })
         .then(() => commit('authSuccess'))
diff --git a/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue b/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue
index 6ca43f3..b81dba6 100644
--- a/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue
+++ b/src/views/AccessControl/LocalUserManagement/LocalUserManagement.vue
@@ -2,8 +2,8 @@
   <b-container class="ml-0">
     <PageTitle />
     <b-row>
-      <b-col lg="10">
-        <b-button @click="initModalSettings" variant="link">
+      <b-col lg="10" class="text-right">
+        <b-button variant="link" @click="initModalSettings">
           Account policy settings
           <icon-settings />
         </b-button>
@@ -15,11 +15,11 @@
     </b-row>
     <b-row>
       <b-col lg="10">
-        <b-table bordered show-empty head-variant="dark" :items="tableItems">
-          <template v-slot:head(actions)="data"></template>
+        <b-table show-empty :fields="fields" :items="tableItems">
           <template v-slot:cell(actions)="data">
             <b-button
               aria-label="Edit user"
+              title="Edit user"
               variant="link"
               :disabled="!data.value.edit"
               @click="initModalUser(data.item)"
@@ -28,6 +28,7 @@
             </b-button>
             <b-button
               aria-label="Delete user"
+              title="Delete user"
               variant="link"
               :disabled="!data.value.delete"
               @click="initModalDelete(data.item)"
@@ -42,6 +43,7 @@
       <b-col lg="8">
         <b-button v-b-toggle.collapse-role-table variant="link" class="mt-3">
           View privilege role descriptions
+          <icon-chevron />
         </b-button>
         <b-collapse id="collapse-role-table" class="mt-3">
           <table-roles />
@@ -49,12 +51,8 @@
       </b-col>
     </b-row>
     <!-- Modals -->
-    <modal-settings v-bind:settings="settings"></modal-settings>
-    <modal-user
-      v-bind:user="activeUser"
-      @ok="saveUser"
-      @hidden="clearActiveUser"
-    ></modal-user>
+    <modal-settings :settings="settings"></modal-settings>
+    <modal-user :user="activeUser" @ok="saveUser"></modal-user>
   </b-container>
 </template>
 
@@ -63,6 +61,7 @@
 import IconEdit from '@carbon/icons-vue/es/edit/20';
 import IconAdd from '@carbon/icons-vue/es/add--alt/20';
 import IconSettings from '@carbon/icons-vue/es/settings/20';
+import IconChevron from '@carbon/icons-vue/es/chevron--up/20';
 
 import TableRoles from './TableRoles';
 import ModalUser from './ModalUser';
@@ -73,6 +72,7 @@
   name: 'local-users',
   components: {
     IconAdd,
+    IconChevron,
     IconEdit,
     IconSettings,
     IconTrashcan,
@@ -84,7 +84,17 @@
   data() {
     return {
       activeUser: null,
-      settings: null
+      settings: null,
+      fields: [
+        'username',
+        'privilege',
+        'status',
+        {
+          key: 'actions',
+          label: '',
+          tdClass: 'table-cell__actions'
+        }
+      ]
     };
   },
   created() {
@@ -108,7 +118,8 @@
           actions: {
             edit: true,
             delete: user.UserName === 'root' ? false : true
-          }
+          },
+          ...user
         };
       });
     }
@@ -143,18 +154,15 @@
         // fetch settings then show modal
       }
     },
-    saveUser({ newUser, form }) {
-      if (newUser) {
-        this.$store.dispatch('localUsers/createUser', form);
+    saveUser({ isNewUser, userData }) {
+      if (isNewUser) {
+        this.$store.dispatch('localUsers/createUser', userData);
       } else {
-        this.$store.dispatch('localUsers/updateUser', form);
+        this.$store.dispatch('localUsers/updateUser', userData);
       }
     },
     deleteUser({ username }) {
       this.$store.dispatch('localUsers/deleteUser', username);
-    },
-    clearActiveUser() {
-      this.activeUser = null;
     }
   }
 };
@@ -164,4 +172,9 @@
 h1 {
   margin-bottom: 2rem;
 }
+.btn.collapsed {
+  svg {
+    transform: rotate(180deg);
+  }
+}
 </style>
diff --git a/src/views/AccessControl/LocalUserManagement/ModalUser.vue b/src/views/AccessControl/LocalUserManagement/ModalUser.vue
index 73aa164..e3ceb7d 100644
--- a/src/views/AccessControl/LocalUserManagement/ModalUser.vue
+++ b/src/views/AccessControl/LocalUserManagement/ModalUser.vue
@@ -1,9 +1,5 @@
 <template>
-  <b-modal
-    id="modal-user"
-    @ok="$emit('ok', { newUser, form })"
-    @hidden="$emit('hidden')"
-  >
+  <b-modal id="modal-user" ref="modal" @ok="onOk" @hidden="resetForm">
     <template v-slot:modal-title>
       <template v-if="newUser">
         Add user
@@ -12,27 +8,121 @@
         Edit user
       </template>
     </template>
-    <b-form>
-      <b-form-group label="Account status">
-        <b-form-radio v-model="form.status" name="user-status" value="true"
-          >Enabled</b-form-radio
-        >
-        <b-form-radio v-model="form.status" name="user-status" value="false"
-          >Disabled</b-form-radio
-        >
-      </b-form-group>
-      <b-form-group label="Username">
-        <b-form-input type="text" v-model="form.username" />
-      </b-form-group>
-      <b-form-group label="Privilege">
-        <b-form-select
-          v-model="form.privilege"
-          :options="privilegeTypes"
-        ></b-form-select>
-      </b-form-group>
-      <b-form-group label="Password">
-        <b-form-input type="password" v-model="form.password" />
-      </b-form-group>
+    <b-form novalidate @submit="handleSubmit">
+      <b-container>
+        <b-row>
+          <b-col>
+            <b-form-group label="Account status">
+              <b-form-radio
+                v-model="form.status"
+                name="user-status"
+                :value="true"
+                @input="$v.form.status.$touch()"
+              >
+                Enabled
+              </b-form-radio>
+              <b-form-radio
+                v-model="form.status"
+                name="user-status"
+                :value="false"
+                @input="$v.form.status.$touch()"
+              >
+                Disabled
+              </b-form-radio>
+            </b-form-group>
+            <b-form-group label="Username" label-for="username">
+              <b-form-text id="username-help-block">
+                Cannot start with a number
+                <br />
+                No special characters except underscore
+              </b-form-text>
+              <b-form-input
+                v-model="form.username"
+                type="text"
+                id="username"
+                aria-describedby="username-help-block"
+                :state="getValidationState($v.form.username)"
+                :disabled="!newUser && originalUsername === 'root'"
+              />
+              <b-form-invalid-feedback role="alert">
+                <template v-if="!$v.form.username.required">
+                  Field required
+                </template>
+                <template v-else-if="!$v.form.username.maxLength">
+                  Length must be between 1 – 16 characters
+                </template>
+                <template v-else-if="!$v.form.username.pattern">
+                  Invalid format
+                </template>
+              </b-form-invalid-feedback>
+            </b-form-group>
+            <b-form-group label="Privilege">
+              <b-form-select
+                v-model="form.privilege"
+                :options="privilegeTypes"
+                :state="getValidationState($v.form.privilege)"
+                @input="$v.form.privilege.$touch()"
+              >
+              </b-form-select>
+              <b-form-invalid-feedback role="alert">
+                <template v-if="!$v.form.privilege.required">
+                  Field required
+                </template>
+              </b-form-invalid-feedback>
+            </b-form-group>
+          </b-col>
+          <b-col>
+            <b-form-group label="User password" label-for="password">
+              <b-form-text id="password-help-block" text-variant="black">
+                <!-- TODO: Should be dynamic values -->
+                Password must between 8 – 20 characters
+              </b-form-text>
+              <b-form-input
+                v-model="form.password"
+                type="password"
+                id="password"
+                aria-describedby="password-help-block"
+                :state="getValidationState($v.form.password)"
+                @input="$v.form.password.$touch()"
+              />
+              <b-form-invalid-feedback role="alert">
+                <template v-if="!$v.form.password.required">
+                  Field required
+                </template>
+                <template
+                  v-if="
+                    !$v.form.password.minLength || !$v.form.password.maxLength
+                  "
+                >
+                  Length must be between 8 – 20 characters
+                </template>
+              </b-form-invalid-feedback>
+            </b-form-group>
+            <b-form-group
+              label="Confirm user password"
+              label-for="password-confirmation"
+            >
+              <b-form-input
+                v-model="form.passwordConfirmation"
+                type="password"
+                id="password-confirmation"
+                :state="getValidationState($v.form.passwordConfirmation)"
+                @input="$v.form.passwordConfirmation.$touch()"
+              />
+              <b-form-invalid-feedback role="alert">
+                <template v-if="!$v.form.passwordConfirmation.required">
+                  Field required
+                </template>
+                <template
+                  v-else-if="!$v.form.passwordConfirmation.sameAsPassword"
+                >
+                  Passwords do not match
+                </template>
+              </b-form-invalid-feedback>
+            </b-form-group>
+          </b-col>
+        </b-row>
+      </b-container>
     </b-form>
     <template v-slot:modal-ok>
       <template v-if="newUser">
@@ -46,29 +136,133 @@
 </template>
 
 <script>
+import {
+  required,
+  maxLength,
+  minLength,
+  sameAs,
+  helpers,
+  requiredIf
+} from 'vuelidate/lib/validators';
+import VuelidateMixin from '../../../components/Mixins/VuelidateMixin.js';
+
 export default {
   props: ['user'],
+  mixins: [VuelidateMixin],
   data() {
     return {
-      privilegeTypes: ['Administrator', 'Operator', 'ReadOnly', 'NoAccess']
+      privilegeTypes: ['Administrator', 'Operator', 'ReadOnly', 'NoAccess'],
+      originalUsername: '',
+      form: {
+        status: true,
+        username: '',
+        privilege: '',
+        password: '',
+        passwordConfirmation: ''
+      }
     };
   },
   computed: {
     newUser() {
       return this.user ? false : true;
+    }
+  },
+  watch: {
+    user: function(value) {
+      if (value === null) return;
+      this.originalUsername = value.username;
+      this.form.username = value.username;
+      this.form.status = value.Enabled;
+      this.form.privilege = value.privilege;
+    }
+  },
+  validations: {
+    form: {
+      status: {
+        required
+      },
+      username: {
+        required,
+        maxLength: maxLength(16),
+        pattern: helpers.regex('pattern', /^([a-zA-Z_][a-zA-Z0-9_]*)/)
+      },
+      privilege: {
+        required
+      },
+      password: {
+        required: requiredIf(function() {
+          return this.requirePassword();
+        }),
+        minLength: minLength(8), //TODO: Update to dynamic backend values
+        maxLength: maxLength(20) //TODO: UPdate to dynamic backend values
+      },
+      passwordConfirmation: {
+        required: requiredIf(function() {
+          return this.requirePassword();
+        }),
+        sameAsPassword: sameAs('password')
+      }
+    }
+  },
+  methods: {
+    handleSubmit() {
+      let userData = {};
+
+      if (this.newUser) {
+        this.$v.$touch();
+        if (this.$v.$invalid) return;
+        userData.username = this.form.username;
+        userData.status = this.form.status;
+        userData.privilege = this.form.privilege;
+        userData.password = this.form.password;
+      } else {
+        if (this.$v.$invalid) return;
+        userData.originalUsername = this.originalUsername;
+        if (this.$v.form.status.$dirty) {
+          userData.status = this.form.status;
+        }
+        if (this.$v.form.username.$dirty) {
+          userData.username = this.form.username;
+        }
+        if (this.$v.form.privilege.$dirty) {
+          userData.privilege = this.form.privilege;
+        }
+        if (this.$v.form.password.$dirty) {
+          userData.password = this.form.password;
+        }
+        if (Object.entries(userData).length === 1) {
+          this.closeModal();
+          return;
+        }
+      }
+
+      this.$emit('ok', { isNewUser: this.newUser, userData });
+      this.closeModal();
     },
-    form() {
-      return {
-        originalUsername: this.newUser ? null : this.user.username,
-        status: this.newUser
-          ? true
-          : this.user.status === 'Enabled'
-          ? true
-          : false,
-        username: this.newUser ? '' : this.user.username,
-        privilege: this.newUser ? '' : this.user.privilege,
-        password: ''
-      };
+    closeModal() {
+      this.$nextTick(() => {
+        this.$refs.modal.hide();
+      });
+    },
+    resetForm() {
+      this.form.originalUsername = '';
+      this.form.status = true;
+      this.form.username = '';
+      this.form.privilege = '';
+      this.form.password = '';
+      this.form.passwordConfirmation = '';
+      this.$v.$reset();
+    },
+    requirePassword() {
+      if (this.newUser) return true;
+      if (this.$v.form.password.$dirty) return true;
+      if (this.$v.form.passwordConfirmation.$dirty) return true;
+      return false;
+    },
+    onOk(bvModalEvt) {
+      // prevent modal close
+      bvModalEvt.preventDefault();
+      this.handleSubmit();
     }
   }
 };
diff --git a/src/views/AccessControl/LocalUserManagement/TableRoles.vue b/src/views/AccessControl/LocalUserManagement/TableRoles.vue
index b401966..7ea89da 100644
--- a/src/views/AccessControl/LocalUserManagement/TableRoles.vue
+++ b/src/views/AccessControl/LocalUserManagement/TableRoles.vue
@@ -1,5 +1,5 @@
 <template>
-  <b-table bordered small head-variant="dark" :items="items" :fields="fields">
+  <b-table small :items="items" :fields="fields">
     <template v-slot:cell(administrator)="data">
       <template v-if="data.value">
         <Checkmark20 />
diff --git a/src/views/Login/Login.vue b/src/views/Login/Login.vue
index 706d3ec..4270b3f 100644
--- a/src/views/Login/Login.vue
+++ b/src/views/Login/Login.vue
@@ -16,15 +16,10 @@
 
         <b-col md="6">
           <b-form class="login-form" @submit.prevent="login" novalidate>
-            <b-alert
-              class="login-error"
-              v-if="authStatus == 'error'"
-              show
-              variant="danger"
-            >
+            <b-alert class="login-error" :show="authError" variant="danger">
               <p id="login-error-alert">
                 <strong>{{ errorMsg.title }}</strong>
-                <span v-if="errorMsg.action">{{ errorMsg.action }}</span>
+                <span>{{ errorMsg.action }}</span>
               </p>
             </b-alert>
             <div class="login-form__section">
@@ -32,14 +27,18 @@
               <b-form-input
                 id="username"
                 v-model="userInfo.username"
-                :aria-describedby="
-                  authStatus == 'error' ? 'login-error-alert' : ''
-                "
+                :aria-describedby="authError ? 'login-error-alert' : ''"
+                :state="getValidationState($v.userInfo.username)"
                 type="text"
-                required
                 autofocus="autofocus"
+                @input="$v.userInfo.username.$touch()"
               >
               </b-form-input>
+              <b-form-invalid-feedback role="alert">
+                <template v-if="!$v.userInfo.username.required">
+                  Field required
+                </template>
+              </b-form-invalid-feedback>
             </div>
 
             <div class="login-form__section">
@@ -47,19 +46,25 @@
               <b-form-input
                 id="password"
                 v-model="userInfo.password"
-                :aria-describedby="
-                  authStatus == 'error' ? 'login-error-alert' : ''
-                "
+                :aria-describedby="authError ? 'login-error-alert' : ''"
+                :state="getValidationState($v.userInfo.password)"
                 type="password"
-                required
+                @input="$v.userInfo.password.$touch()"
               >
               </b-form-input>
+              <b-form-invalid-feedback role="alert">
+                <template v-if="!$v.userInfo.password.required">
+                  Field required
+                </template>
+              </b-form-invalid-feedback>
             </div>
 
             <b-button
+              block
+              class="mt-5"
               type="submit"
               variant="primary"
-              :disabled="authStatus == 'processing'"
+              :disabled="disableSubmitButton"
               >Log in</b-button
             >
           </b-form>
@@ -70,18 +75,22 @@
 </template>
 
 <script>
+import { required } from 'vuelidate/lib/validators';
+import VuelidateMixin from '../../components/Mixins/VuelidateMixin.js';
+
 export default {
   name: 'Login',
+  mixins: [VuelidateMixin],
   computed: {
-    authStatus() {
-      return this.$store.getters['authentication/authStatus'];
+    authError() {
+      return this.$store.getters['authentication/authError'];
     }
   },
   data() {
     return {
       errorMsg: {
-        title: null,
-        action: null
+        title: 'Invalid username or password.',
+        action: 'Please try again.'
       },
       userInfo: {
         username: null,
@@ -90,46 +99,34 @@
       disableSubmitButton: false
     };
   },
+  validations: {
+    userInfo: {
+      username: {
+        required
+      },
+      password: {
+        required
+      }
+    }
+  },
   methods: {
-    resetState: function() {
-      this.errorMsg.title = null;
-      this.errorMsg.action = null;
-      this.$store.commit('authentication/authReset');
-    },
-    validateRequiredFields: function() {
-      if (!this.userInfo.username || !this.userInfo.password) {
-        this.$store.commit('authentication/authError');
-      }
-    },
     login: function() {
-      this.resetState();
-      this.validateRequiredFields();
-      if (this.authStatus !== 'error') {
-        const username = this.userInfo.username;
-        const password = this.userInfo.password;
-        this.$store
-          .dispatch('authentication/login', [username, password])
-          .then(() => {
-            this.$router.push('/');
-          })
-          .catch(error => {
-            this.errorMsg.title = 'Invalid username or password.';
-            this.errorMsg.action = 'Please try again.';
-            console.log(error);
-          });
-      } else {
-        this.errorMsg.title = 'Username and password required.';
-      }
+      this.$v.$touch();
+      if (this.$v.$invalid) return;
+      this.disableSubmitButton = true;
+      const username = this.userInfo.username;
+      const password = this.userInfo.password;
+      this.$store
+        .dispatch('authentication/login', [username, password])
+        .then(() => this.$router.push('/'))
+        .catch(error => console.log(error))
+        .finally(() => (this.disableSubmitButton = false));
     }
   }
 };
 </script>
 
 <style lang="scss" scoped>
-@import '~bootstrap/scss/functions';
-@import '~bootstrap/scss/variables';
-@import '~bootstrap/scss/mixins';
-
 .login-container {
   @include media-breakpoint-up(md) {
     background: linear-gradient(