Set up initial language translation

- Add i18n internationalization plugin
- Create json files for group 0 English and Spanish
- Uses $t method to set up initial translations on login page
- Meta title is translated using i18n in App.vue and PageTitle.Vue

Signed-off-by: Dixsie Wolmers <dixsie@ibm.com>
Change-Id: Ifce9f5e54d96f8b2a13239ad6178892f99fc4537
diff --git a/package-lock.json b/package-lock.json
index 030b915..d07b4e7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1198,6 +1198,12 @@
         "@types/yargs": "^13.0.0"
       }
     },
+    "@kazupon/vue-i18n-loader": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@kazupon/vue-i18n-loader/-/vue-i18n-loader-0.3.0.tgz",
+      "integrity": "sha512-M2280E9PMxetu6mOdtyh1d6Dif7LwH4gvxD2dgsu7HOyzR26AUNok8DxZ1Y5YAexJvPfbBXC75Llui2myO05Hg==",
+      "dev": true
+    },
     "@mrmlnc/readdir-enhanced": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
@@ -4362,6 +4368,44 @@
       "integrity": "sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ==",
       "dev": true
     },
+    "cli-table3": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.5.1.tgz",
+      "integrity": "sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw==",
+      "dev": true,
+      "requires": {
+        "colors": "^1.1.2",
+        "object-assign": "^4.1.0",
+        "string-width": "^2.1.1"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
+          "dev": true
+        },
+        "string-width": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+          "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+          "dev": true,
+          "requires": {
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^4.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "^3.0.0"
+          }
+        }
+      }
+    },
     "cli-truncate": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz",
@@ -4548,6 +4592,13 @@
         "simple-swizzle": "^0.2.2"
       }
     },
+    "colors": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
+      "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
+      "dev": true,
+      "optional": true
+    },
     "combined-stream": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -5862,6 +5913,16 @@
         "domelementtype": "1"
       }
     },
+    "dot-object": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-1.9.0.tgz",
+      "integrity": "sha512-7MPN6y7XhAO4vM4eguj5+5HNKLjJYfkVG1ZR1Aput4Q4TR6SYeSjhpVQ77IzJHoSHffKbDxBC+48aCiiRurDPw==",
+      "dev": true,
+      "requires": {
+        "commander": "^2.20.0",
+        "glob": "^7.1.4"
+      }
+    },
     "dot-prop": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz",
@@ -6319,6 +6380,12 @@
       "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==",
       "dev": true
     },
+    "esm": {
+      "version": "3.2.25",
+      "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
+      "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
+      "dev": true
+    },
     "espree": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/espree/-/espree-5.0.1.tgz",
@@ -6878,6 +6945,15 @@
         "locate-path": "^3.0.0"
       }
     },
+    "flat": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.0.tgz",
+      "integrity": "sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw==",
+      "dev": true,
+      "requires": {
+        "is-buffer": "~2.0.3"
+      }
+    },
     "flat-cache": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz",
@@ -8990,6 +9066,12 @@
       "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=",
       "dev": true
     },
+    "is-valid-glob": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz",
+      "integrity": "sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao=",
+      "dev": true
+    },
     "is-whitespace": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz",
@@ -15993,6 +16075,36 @@
       "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz",
       "integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ=="
     },
+    "vue-cli-plugin-i18n": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/vue-cli-plugin-i18n/-/vue-cli-plugin-i18n-0.6.1.tgz",
+      "integrity": "sha512-/2T/T47x8Aj3xLfrNYe5L1pzw+TnoDdKahcQflsPPjkXCuDss4rRVXVH0zzI3gpx2B1s9WW+yMYsg0JUlxgGEw==",
+      "dev": true,
+      "requires": {
+        "debug": "^3.1.0",
+        "deepmerge": "^2.1.1",
+        "dotenv": "^6.0.0",
+        "flat": "^4.0.0",
+        "rimraf": "^2.6.3",
+        "vue": "^2.5.16",
+        "vue-i18n": "^8.0.0",
+        "vue-i18n-extract": "^1.0.2"
+      },
+      "dependencies": {
+        "deepmerge": {
+          "version": "2.2.1",
+          "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
+          "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
+          "dev": true
+        },
+        "dotenv": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.2.0.tgz",
+          "integrity": "sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==",
+          "dev": true
+        }
+      }
+    },
     "vue-date-fns": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/vue-date-fns/-/vue-date-fns-1.1.0.tgz",
@@ -16054,6 +16166,25 @@
       "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
       "dev": true
     },
+    "vue-i18n": {
+      "version": "8.15.3",
+      "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.15.3.tgz",
+      "integrity": "sha512-PVNgo6yhOmacZVFjSapZ314oewwLyXHjJwAqjnaPN1GJAJd/dvsrShGzSiJuCX4Hc36G4epJvNXUwO8y7wEKew=="
+    },
+    "vue-i18n-extract": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/vue-i18n-extract/-/vue-i18n-extract-1.0.2.tgz",
+      "integrity": "sha512-+zwDKvle4KcfloXZnj5hF01ViKDiFr5RMx5507D7oyDXpSleRpekF5YHgZa/+Ra6Go68//z0Nya58J9tKFsCjw==",
+      "dev": true,
+      "requires": {
+        "cli-table3": "^0.5.1",
+        "dot-object": "^1.7.1",
+        "esm": "^3.2.13",
+        "glob": "^7.1.3",
+        "is-valid-glob": "^1.0.0",
+        "yargs": "^13.2.2"
+      }
+    },
     "vue-jest": {
       "version": "3.0.5",
       "resolved": "https://registry.npmjs.org/vue-jest/-/vue-jest-3.0.5.tgz",
diff --git a/package.json b/package.json
index 188545d..361adb2 100644
--- a/package.json
+++ b/package.json
@@ -1,15 +1,16 @@
 {
   "name": "webui-vue",
-  "description": "OpenBMC Web UI using the Vue.js front-end framework",
   "version": "0.1.0",
   "private": true,
+  "description": "OpenBMC Web UI using the Vue.js front-end framework",
   "scripts": {
     "serve": "vue-cli-service serve",
     "build": "vue-cli-service build",
     "test:unit": "vue-cli-service test:unit",
     "lint": "vue-cli-service lint",
     "docs:serve": "vuepress dev docs",
-    "docs:build": "vuepress build docs"
+    "docs:build": "vuepress build docs",
+    "i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'"
   },
   "dependencies": {
     "@carbon/icons-vue": "10.6.1",
@@ -20,11 +21,13 @@
     "js-cookie": "^2.2.1",
     "vue": "2.6.11",
     "vue-date-fns": "1.1.0",
+    "vue-i18n": "8.0.0",
     "vue-router": "3.1.3",
     "vuelidate": "^0.7.4",
     "vuex": "3.0.1"
   },
   "devDependencies": {
+    "@kazupon/vue-i18n-loader": "0.3.0",
     "@vue/cli-plugin-babel": "4.0.0",
     "@vue/cli-plugin-eslint": "4.0.5",
     "@vue/cli-plugin-router": "4.0.0",
@@ -43,7 +46,8 @@
     "prettier": "1.18.2",
     "sass-loader": "8.0.0",
     "vue-template-compiler": "2.6.11",
-    "vuepress": "^1.2.0"
+    "vuepress": "^1.2.0",
+    "vue-cli-plugin-i18n": "0.6.1"
   },
   "gitHooks": {
     "pre-commit": "lint-staged"
diff --git a/src/App.vue b/src/App.vue
index a5a768a..30de752 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -13,7 +13,7 @@
   name: 'App',
   watch: {
     $route: function(to) {
-      document.title = to.meta.title || 'Page is Missing Title';
+      document.title = this.$t(to.meta.title) || 'Page is missing title';
     }
   }
 };
diff --git a/src/components/Global/PageTitle.vue b/src/components/Global/PageTitle.vue
index 5c64f0d..59bb6a1 100644
--- a/src/components/Global/PageTitle.vue
+++ b/src/components/Global/PageTitle.vue
@@ -14,10 +14,10 @@
       default: ''
     }
   },
-  data() {
-    return {
-      title: this.$route.meta.title
-    };
+  computed: {
+    title() {
+      return this.$t(this.$route.meta.title);
+    }
   }
 };
 </script>
diff --git a/src/i18n.js b/src/i18n.js
new file mode 100644
index 0000000..09b3f4c
--- /dev/null
+++ b/src/i18n.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import VueI18n from 'vue-i18n';
+
+Vue.use(VueI18n);
+
+function loadLocaleMessages() {
+  const locales = require.context(
+    './locales',
+    true,
+    /[A-Za-z0-9-_,\s]+\.json$/i
+  );
+  const messages = {};
+  locales.keys().forEach(key => {
+    const matched = key.match(/([A-Za-z0-9-_]+)\./i);
+    if (matched && matched.length > 1) {
+      const locale = matched[1];
+      messages[locale] = locales(key);
+    }
+  });
+  return messages;
+}
+
+export default new VueI18n({
+  // default language is English
+  locale: 'en',
+  // locale messages with a message key that doesn't exist will fallback to English
+  fallbackLocale: 'en',
+  messages: loadLocaleMessages()
+});
diff --git a/src/locales/en.json b/src/locales/en.json
new file mode 100644
index 0000000..8464ff4
--- /dev/null
+++ b/src/locales/en.json
@@ -0,0 +1,38 @@
+{
+  "global": {
+    "formField": {
+      "validator": "Field required"
+    }
+  },
+  "login": {
+    "language": {
+      "label": "Language"
+    },
+    "languages": {
+      "select": "Select an option",
+      "english": "English",
+      "spanish": "Spanish"
+    },
+    "logIn": {
+      "label": "Log in"
+    },
+    "errorMsg": {
+      "title": "Invalid username or password.",
+      "action": "Please try again."
+    },
+    "password": {
+      "label": "Password",
+      "validator": "@:global.formField.validator"
+    },
+    "username": {
+      "label": "Username",
+      "validator": "@:global.formField.validator"
+    }
+  },
+  "pageTitle": {
+    "localUserMgmt": "Local user management",
+    "login": "Login",
+    "overview": "Overview",
+    "unauthorized": "Unauthorized"
+  }
+}
\ No newline at end of file
diff --git a/src/locales/es.json b/src/locales/es.json
new file mode 100644
index 0000000..30d1fd1
--- /dev/null
+++ b/src/locales/es.json
@@ -0,0 +1,38 @@
+{
+  "global": {
+    "formField": {
+      "validator": "Campo requerido"
+    }
+  },
+  "login": {
+    "language": {
+      "label": "Idioma"
+    },
+    "languages": {
+      "select": "Seleccione una opción",
+      "english": "Inglés",
+      "spanish": "Español"
+    },
+    "logIn": {
+      "label": "Iniciar sesión"
+    },
+    "errorMsg": {
+      "title": "Usuario o contraseña invalido.",
+      "action": "Inténtalo de nuevo."
+    },
+    "password": {
+      "label": "Contraseña",
+      "validator": "@:global.formField.validator"
+    },
+    "username": {
+      "label": "Nombre de usuario",
+      "validator": "@:global.formField.validator"
+    }
+  },
+  "pageTitle": {
+    "localUserMgmt": "Administración de usuarios locales",
+    "login": "Inicio de sesión",
+    "overview": "Información general",
+    "unauthorized": "No autorizado"
+  }
+}
\ No newline at end of file
diff --git a/src/main.js b/src/main.js
index d80d201..7216751 100644
--- a/src/main.js
+++ b/src/main.js
@@ -25,6 +25,7 @@
   ToastPlugin
 } from 'bootstrap-vue';
 import Vuelidate from 'vuelidate';
+import i18n from './i18n';
 
 Vue.filter('date', dateFilter);
 
@@ -59,5 +60,6 @@
 new Vue({
   router,
   store,
+  i18n,
   render: h => h(App)
 }).$mount('#app');
diff --git a/src/router/index.js b/src/router/index.js
index 71b90fb..bec7f54 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -5,6 +5,8 @@
 
 Vue.use(VueRouter);
 
+// Meta title is translated using i18n in App.vue and PageTitle.Vue
+// Example meta: {title: 'pageTitle.overview'}
 const routes = [
   {
     path: '/',
@@ -18,7 +20,7 @@
         path: '',
         component: () => import('@/views/Overview'),
         meta: {
-          title: 'Overview'
+          title: 'pageTitle.overview'
         }
       },
       {
@@ -26,7 +28,7 @@
         name: 'local-users',
         component: () => import('@/views/AccessControl/LocalUserManagement'),
         meta: {
-          title: 'Local user management'
+          title: 'pageTitle.localUserMgmt'
         }
       },
       {
@@ -34,7 +36,7 @@
         name: 'unauthorized',
         component: () => import('@/views/Unauthorized'),
         meta: {
-          title: 'Unauthorized'
+          title: 'pageTitle.unauthorized'
         }
       }
     ]
@@ -44,7 +46,7 @@
     name: 'login',
     component: () => import('@/views/Login'),
     meta: {
-      title: 'Login'
+      title: 'pageTitle.login'
     }
   }
 ];
diff --git a/src/views/Login/Login.vue b/src/views/Login/Login.vue
index 35af76f..d4fde8c 100644
--- a/src/views/Login/Login.vue
+++ b/src/views/Login/Login.vue
@@ -13,17 +13,24 @@
             <h1>OpenBMC</h1>
           </div>
         </b-col>
-
         <b-col md="6">
           <b-form class="login-form" novalidate @submit.prevent="login">
             <b-alert class="login-error" :show="authError" variant="danger">
               <p id="login-error-alert">
-                <strong>{{ errorMsg.title }}</strong>
-                <span>{{ errorMsg.action }}</span>
+                <strong>{{ $t('login.errorMsg.title') }}</strong>
+                <span>{{ $t('login.errorMsg.action') }}</span>
               </p>
             </b-alert>
             <div class="login-form__section">
-              <label for="username">Username</label>
+              <label for="language">{{ $t('login.language.label') }}</label>
+              <b-form-select
+                id="language"
+                v-model="$i18n.locale"
+                :options="languages"
+              ></b-form-select>
+            </div>
+            <div class="login-form__section">
+              <label for="username">{{ $t('login.username.label') }}</label>
               <b-form-input
                 id="username"
                 v-model="userInfo.username"
@@ -36,13 +43,12 @@
               </b-form-input>
               <b-form-invalid-feedback role="alert">
                 <template v-if="!$v.userInfo.username.required">
-                  Field required
+                  {{ $t('login.username.validator') }}
                 </template>
               </b-form-invalid-feedback>
             </div>
-
             <div class="login-form__section">
-              <label for="password">Password</label>
+              <label for="password">{{ $t('login.password.label') }}</label>
               <b-form-input
                 id="password"
                 v-model="userInfo.password"
@@ -54,18 +60,17 @@
               </b-form-input>
               <b-form-invalid-feedback role="alert">
                 <template v-if="!$v.userInfo.password.required">
-                  Field required
+                  {{ $t('login.password.validator') }}
                 </template>
               </b-form-invalid-feedback>
             </div>
-
             <b-button
               block
               class="mt-5"
               type="submit"
               variant="primary"
               :disabled="disableSubmitButton"
-              >Log in</b-button
+              >{{ $t('login.logIn.label') }}</b-button
             >
           </b-form>
         </b-col>
@@ -83,15 +88,22 @@
   mixins: [VuelidateMixin],
   data() {
     return {
-      errorMsg: {
-        title: 'Invalid username or password.',
-        action: 'Please try again.'
-      },
       userInfo: {
         username: null,
         password: null
       },
-      disableSubmitButton: false
+      disableSubmitButton: false,
+      languages: [
+        { value: null, text: this.$t('login.languages.select') },
+        {
+          value: 'en',
+          text: this.$t('login.languages.english')
+        },
+        {
+          value: 'es',
+          text: this.$t('login.languages.spanish')
+        }
+      ]
     };
   },
   computed: {
diff --git a/src/views/Overview/OverviewQuickLinks.vue b/src/views/Overview/OverviewQuickLinks.vue
index d9d86ca..8925397 100644
--- a/src/views/Overview/OverviewQuickLinks.vue
+++ b/src/views/Overview/OverviewQuickLinks.vue
@@ -43,7 +43,7 @@
   },
   data() {
     return {
-      serverLEDChecked: false
+      serverLedChecked: false
     };
   },
   computed: {
diff --git a/vue.config.js b/vue.config.js
index e40b01e..12a723d 100644
--- a/vue.config.js
+++ b/vue.config.js
@@ -16,7 +16,7 @@
       '/': {
         target: process.env.BASE_URL,
         onProxyRes: proxyRes => {
-          // This header is igorned in the browser so removing
+          // This header is ignored in the browser so removing
           // it so we don't see warnings in the browser console
           delete proxyRes.headers['strict-transport-security'];
         }
@@ -39,5 +39,11 @@
       config.plugins.delete('prefetch');
       config.plugins.delete('preload');
     }
+  },
+  pluginOptions: {
+    i18n: {
+      localeDir: 'locales',
+      enableInSFC: true
+    }
   }
 };