Add SSL Certificates page

Adds ability to view, add, replace, and delete SSL
certificates in GUI.

Signed-off-by: Yoshie Muranaka <yoshiemuranaka@gmail.com>
Change-Id: I5cf9fa7bbd588dfb22f2431eed0b5976ff860703
diff --git a/src/assets/styles/_form-components.scss b/src/assets/styles/_form-components.scss
index d9ae9d4..e7a7b0c 100644
--- a/src/assets/styles/_form-components.scss
+++ b/src/assets/styles/_form-components.scss
@@ -34,4 +34,4 @@
       color: $gray-700!important;
     }
   }
-}
+}
\ No newline at end of file
diff --git a/src/components/AppNavigation/AppNavigation.vue b/src/components/AppNavigation/AppNavigation.vue
index d0fee43..f2f049b 100644
--- a/src/components/AppNavigation/AppNavigation.vue
+++ b/src/components/AppNavigation/AppNavigation.vue
@@ -81,7 +81,7 @@
               <b-nav-item to="/access-control/local-user-management">
                 {{ $t('appNavigation.localUserManagement') }}
               </b-nav-item>
-              <b-nav-item href="javascript:void(0)">
+              <b-nav-item to="/access-control/ssl-certificates">
                 {{ $t('appNavigation.sslCertificates') }}
               </b-nav-item>
             </b-collapse>
diff --git a/src/locales/en-US.json b/src/locales/en-US.json
index 19b2082..d1d5f61 100644
--- a/src/locales/en-US.json
+++ b/src/locales/en-US.json
@@ -1,11 +1,13 @@
 {
   "global": {
     "action": {
+      "add": "Add",
       "confirm": "Confirm",
       "cancel": "Cancel",
       "delete": "Delete",
       "disable": "Disable",
       "enable": "Enable",
+      "replace": "Replace",
       "save": "Save",
       "selected": "Selected"
     },
@@ -17,6 +19,7 @@
       "invalidFormat": "Invalid format",
       "lengthMustBeBetween": "Length must be between %{min} – %{max} characters",
       "mustBeAtLeast": "Must be at least %{value}",
+      "required": "Required",
       "selectAnOption": "Select an option",
       "valueMustBeBetween": "Value must be between %{min} – %{max}"
     },
@@ -227,5 +230,33 @@
       "errorSaveSettings": "Error saving settings.",
       "successSaveSettings": "Successfully saved settings."
     }
+  },
+  "pageSslCertificates": {
+    "addNewCertificate": "Add new certificate",
+    "caCertificate": "CA Certificate",
+    "deleteCertificate": "Delete certificate",
+    "httpsCertificate": "HTTPS Certificate",
+    "ldapCertificate": "LDAP Certificate",
+    "replaceCertificate": "Replace certificate",
+    "modal": {
+      "certificateType": "Certificate type",
+      "certificateFile": "Certificate file",
+      "deleteConfirmMessage": "Are you sure you want to delete '%{certificate}' issued by %{issuedBy}? This action cannot be undone."
+    },
+    "table": {
+      "certificate": "Certificate",
+      "issuedBy": "Issued by",
+      "issuedTo": "Issued to",
+      "validFrom": "Valid from",
+      "validUntil": "Valid until"
+    },
+    "toast": {
+      "errorAddCertificate": "Error adding certificate.",
+      "errorDeleteCertificate": "Error deleting certificate.",
+      "errorReplaceCertificate": "Error replacing certificate.",
+      "successAddCertificate": "Successfully added %{certificate}.",
+      "successDeleteCertificate": "Successfully deleted %{certificate}.",
+      "successReplaceCertificate": "Successfully replaced %{certificate}."
+    }
   }
 }
\ No newline at end of file
diff --git a/src/main.js b/src/main.js
index ab1f296..17fc50f 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,6 +10,7 @@
   CollapsePlugin,
   FormPlugin,
   FormCheckboxPlugin,
+  FormFilePlugin,
   FormGroupPlugin,
   FormInputPlugin,
   FormRadioPlugin,
@@ -64,6 +65,7 @@
 Vue.use(CollapsePlugin);
 Vue.use(FormPlugin);
 Vue.use(FormCheckboxPlugin);
+Vue.use(FormFilePlugin);
 Vue.use(FormGroupPlugin);
 Vue.use(FormInputPlugin);
 Vue.use(FormRadioPlugin);
diff --git a/src/router/index.js b/src/router/index.js
index cd6cf8b..2af53ea 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -39,6 +39,14 @@
         }
       },
       {
+        path: '/access-control/ssl-certificates',
+        name: 'ssl-certificates',
+        component: () => import('@/views/AccessControl/SslCertificates'),
+        meta: {
+          title: 'appPageTitle.sslCertificates'
+        }
+      },
+      {
         path: '/control/reboot-bmc',
         name: 'reboot-bmc',
         component: () => import('@/views/Control/RebootBmc'),
diff --git a/src/store/api.js b/src/store/api.js
index 8fdbdd2..24a38e4 100644
--- a/src/store/api.js
+++ b/src/store/api.js
@@ -29,8 +29,8 @@
   delete(path, payload) {
     return api.delete(path, payload);
   },
-  post(path, payload) {
-    return api.post(path, payload);
+  post(path, payload, config) {
+    return api.post(path, payload, config);
   },
   patch(path, payload) {
     return api.patch(path, payload);
diff --git a/src/store/index.js b/src/store/index.js
index 08ada05..0180213 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -4,6 +4,7 @@
 import GlobalStore from './modules/GlobalStore';
 import AuthenticationStore from './modules/Authentication/AuthenticanStore';
 import LocalUserManagementStore from './modules/AccessControl/LocalUserMangementStore';
+import SslCertificatesStore from './modules/AccessControl/SslCertificatesStore';
 import OverviewStore from './modules/Overview/OverviewStore';
 import FirmwareStore from './modules/Configuration/FirmwareStore';
 import BootSettingsStore from './modules/Control/BootSettingsStore';
@@ -32,7 +33,8 @@
     powerControl: PowerControlStore,
     networkSettings: NetworkSettingStore,
     eventLog: EventLogStore,
-    sensors: SensorsStore
+    sensors: SensorsStore,
+    sslCertificates: SslCertificatesStore
   },
   plugins: [WebSocketPlugin]
 });
diff --git a/src/store/modules/AccessControl/SslCertificatesStore.js b/src/store/modules/AccessControl/SslCertificatesStore.js
new file mode 100644
index 0000000..e1758d3
--- /dev/null
+++ b/src/store/modules/AccessControl/SslCertificatesStore.js
@@ -0,0 +1,158 @@
+import api from '../../api';
+import i18n from '../../../i18n';
+
+const CERTIFICATE_TYPES = [
+  {
+    type: 'HTTPS Certificate',
+    location: '/redfish/v1/Managers/bmc/NetworkProtocol/HTTPS/Certificates/',
+    label: i18n.t('pageSslCertificates.httpsCertificate')
+  },
+  {
+    type: 'LDAP Certificate',
+    location: '/redfish/v1/AccountService/LDAP/Certificates/',
+    label: i18n.t('pageSslCertificates.ldapCertificate')
+  },
+  {
+    type: 'TrustStore Certificate',
+    location: '/redfish/v1/Managers/bmc/Truststore/Certificates/',
+    // Web UI will show 'CA Certificate' instead of
+    // 'TrustStore Certificate' after user testing revealed
+    // the term 'TrustStore Certificate' wasn't recognized/was unfamilar
+    label: i18n.t('pageSslCertificates.caCertificate')
+  }
+];
+
+const getCertificateProp = (type, prop) => {
+  const certificate = CERTIFICATE_TYPES.find(
+    certificate => certificate.type === type
+  );
+  return certificate ? certificate[prop] : null;
+};
+
+const SslCertificatesStore = {
+  namespaced: true,
+  state: {
+    allCertificates: [],
+    availableUploadTypes: []
+  },
+  getters: {
+    allCertificates: state => state.allCertificates,
+    availableUploadTypes: state => state.availableUploadTypes
+  },
+  mutations: {
+    setCertificates(state, certificates) {
+      state.allCertificates = certificates;
+    },
+    setAvailableUploadTypes(state, availableUploadTypes) {
+      state.availableUploadTypes = availableUploadTypes;
+    }
+  },
+  actions: {
+    getCertificates({ commit }) {
+      api
+        .get('/redfish/v1/CertificateService/CertificateLocations')
+        .then(({ data: { Links: { Certificates } } }) =>
+          Certificates.map(certificate => certificate['@odata.id'])
+        )
+        .then(certificateLocations => {
+          const promises = certificateLocations.map(location =>
+            api.get(location)
+          );
+          api.all(promises).then(
+            api.spread((...responses) => {
+              const certificates = responses.map(({ data }) => {
+                const {
+                  Name,
+                  ValidNotAfter,
+                  ValidNotBefore,
+                  Issuer = {},
+                  Subject = {}
+                } = data;
+                return {
+                  type: Name,
+                  location: data['@odata.id'],
+                  certificate: getCertificateProp(Name, 'label'),
+                  issuedBy: Issuer.CommonName,
+                  issuedTo: Subject.CommonName,
+                  validFrom: new Date(ValidNotBefore),
+                  validUntil: new Date(ValidNotAfter)
+                };
+              });
+              const availableUploadTypes = CERTIFICATE_TYPES.filter(
+                ({ type }) =>
+                  !certificates
+                    .map(certificate => certificate.type)
+                    .includes(type)
+              );
+
+              commit('setCertificates', certificates);
+              commit('setAvailableUploadTypes', availableUploadTypes);
+            })
+          );
+        });
+    },
+    async addNewCertificate({ dispatch }, { file, type }) {
+      return await api
+        .post(getCertificateProp(type, 'location'), file, {
+          headers: { 'Content-Type': 'application/x-pem-file' }
+        })
+        .then(() => dispatch('getCertificates'))
+        .then(() =>
+          i18n.t('pageSslCertificates.toast.successAddCertificate', {
+            certificate: getCertificateProp(type, 'label')
+          })
+        )
+        .catch(error => {
+          console.log(error);
+          throw new Error(
+            i18n.t('pageSslCertificates.toast.errorAddCertificate')
+          );
+        });
+    },
+    async replaceCertificate(
+      { dispatch },
+      { certificateString, location, type }
+    ) {
+      const data = {};
+      data.CertificateString = certificateString;
+      data.CertificateType = 'PEM';
+      data.CertificateUri = { '@odata.id': location };
+
+      return await api
+        .post(
+          '/redfish/v1/CertificateService/Actions/CertificateService.ReplaceCertificate',
+          data
+        )
+        .then(() => dispatch('getCertificates'))
+        .then(() =>
+          i18n.t('pageSslCertificates.toast.successReplaceCertificate', {
+            certificate: getCertificateProp(type, 'label')
+          })
+        )
+        .catch(error => {
+          console.log(error);
+          throw new Error(
+            i18n.t('pageSslCertificates.toast.errorReplaceCertificate')
+          );
+        });
+    },
+    async deleteCertificate({ dispatch }, { type, location }) {
+      return await api
+        .delete(location)
+        .then(() => dispatch('getCertificates'))
+        .then(() =>
+          i18n.t('pageSslCertificates.toast.successDeleteCertificate', {
+            certificate: getCertificateProp(type, 'label')
+          })
+        )
+        .catch(error => {
+          console.log(error);
+          throw new Error(
+            i18n.t('pageSslCertificates.toast.errorDeleteCertificate')
+          );
+        });
+    }
+  }
+};
+
+export default SslCertificatesStore;
diff --git a/src/views/AccessControl/SslCertificates/ModalUploadCertificate.vue b/src/views/AccessControl/SslCertificates/ModalUploadCertificate.vue
new file mode 100644
index 0000000..653a232
--- /dev/null
+++ b/src/views/AccessControl/SslCertificates/ModalUploadCertificate.vue
@@ -0,0 +1,164 @@
+<template>
+  <b-modal id="upload-certificate" ref="modal" @ok="onOk" @hidden="resetForm">
+    <template v-slot:modal-title>
+      <template v-if="certificate">
+        {{ $t('pageSslCertificates.replaceCertificate') }}
+      </template>
+      <template v-else>
+        {{ $t('pageSslCertificates.addNewCertificate') }}
+      </template>
+    </template>
+    <b-form>
+      <!-- Replace Certificate type -->
+      <template v-if="certificate !== null">
+        <dl class="mb-4">
+          <dt>{{ $t('pageSslCertificates.modal.certificateType') }}</dt>
+          <dd>{{ certificate.certificate }}</dd>
+        </dl>
+      </template>
+
+      <!-- Add new Certificate type -->
+      <template v-else>
+        <b-form-group
+          :label="$t('pageSslCertificates.modal.certificateType')"
+          label-for="certificate-type"
+        >
+          <b-form-select
+            id="certificate-type"
+            v-model="form.certificateType"
+            :options="certificateOptions"
+            :state="getValidationState($v.form.certificateType)"
+            @input="$v.form.certificateType.$touch()"
+          >
+          </b-form-select>
+          <b-form-invalid-feedback role="alert">
+            <template v-if="!$v.form.certificateType.required">
+              {{ $t('global.form.fieldRequired') }}
+            </template>
+          </b-form-invalid-feedback>
+        </b-form-group>
+      </template>
+
+      <b-form-group
+        :label="$t('pageSslCertificates.modal.certificateFile')"
+        label-for="certificate-file"
+      >
+        <b-form-file
+          id="certificate-file"
+          v-model="form.file"
+          accept=".pem"
+          plain
+          :state="getValidationState($v.form.file)"
+        />
+        <b-form-invalid-feedback role="alert">
+          <template v-if="!$v.form.file.required">
+            {{ $t('global.form.required') }}
+          </template>
+        </b-form-invalid-feedback>
+      </b-form-group>
+    </b-form>
+    <template v-slot:modal-ok>
+      <template v-if="certificate">
+        {{ $t('global.action.replace') }}
+      </template>
+      <template v-else>
+        {{ $t('global.action.add') }}
+      </template>
+    </template>
+  </b-modal>
+</template>
+
+<script>
+import { required, requiredIf } from 'vuelidate/lib/validators';
+import VuelidateMixin from '../../../components/Mixins/VuelidateMixin.js';
+
+export default {
+  mixins: [VuelidateMixin],
+  props: {
+    certificate: {
+      type: Object,
+      default: null,
+      validator: prop => {
+        if (prop === null) return true;
+        return (
+          prop.hasOwnProperty('type') && prop.hasOwnProperty('certificate')
+        );
+      }
+    }
+  },
+  data() {
+    return {
+      form: {
+        certificateType: null,
+        file: null
+      }
+    };
+  },
+  computed: {
+    certificateTypes() {
+      return this.$store.getters['sslCertificates/availableUploadTypes'];
+    },
+    certificateOptions() {
+      return this.certificateTypes.map(({ type, label }) => {
+        return {
+          text: label,
+          value: type
+        };
+      });
+    }
+  },
+  watch: {
+    certificateOptions: function(options) {
+      if (options.length) {
+        this.form.certificateType = options[0].value;
+      }
+    }
+  },
+  validations() {
+    return {
+      form: {
+        certificateType: {
+          required: requiredIf(function() {
+            return !this.certificate;
+          })
+        },
+        file: {
+          required
+        }
+      }
+    };
+  },
+  methods: {
+    handleSubmit() {
+      this.$v.$touch();
+      if (this.$v.$invalid) return;
+      this.$emit('ok', {
+        addNew: !this.certificate,
+        file: this.form.file,
+        location: this.certificate ? this.certificate.location : null,
+        type: this.certificate
+          ? this.certificate.type
+          : this.form.certificateType
+      });
+      this.closeModal();
+    },
+    closeModal() {
+      this.$nextTick(() => {
+        this.$refs.modal.hide();
+      });
+    },
+    resetForm() {
+      this.form.certificateType = this.certificateOptions.length
+        ? this.certificateOptions[0].value
+        : null;
+      this.form.file = null;
+      this.$v.$reset();
+    },
+    onOk(bvModalEvt) {
+      // prevent modal close
+      bvModalEvt.preventDefault();
+      this.handleSubmit();
+    }
+  }
+};
+</script>
diff --git a/src/views/AccessControl/SslCertificates/SslCertificates.vue b/src/views/AccessControl/SslCertificates/SslCertificates.vue
new file mode 100644
index 0000000..ae28271
--- /dev/null
+++ b/src/views/AccessControl/SslCertificates/SslCertificates.vue
@@ -0,0 +1,209 @@
+<template>
+  <b-container fluid>
+    <page-title />
+    <b-row>
+      <b-col xl="9" class="text-right">
+        <b-button
+          variant="primary"
+          :disabled="certificatesForUpload.length === 0"
+          @click="initModalUploadCertificate(null)"
+        >
+          <icon-add />
+          {{ $t('pageSslCertificates.addNewCertificate') }}
+        </b-button>
+      </b-col>
+    </b-row>
+    <b-row>
+      <b-col xl="9">
+        <b-table :fields="fields" :items="tableItems">
+          <template v-slot:cell(validFrom)="{ value }">
+            {{ value | formatDate }}
+          </template>
+
+          <template v-slot:cell(validUntil)="{ value }">
+            {{ value | formatDate }}
+          </template>
+
+          <template v-slot:cell(actions)="{ value, item }">
+            <table-row-action
+              v-for="(action, index) in value"
+              :key="index"
+              :value="action.value"
+              :title="action.title"
+              :enabled="action.enabled"
+              @click:tableAction="onTableRowAction($event, item)"
+            >
+              <template v-slot:icon>
+                <icon-replace v-if="action.value === 'replace'" />
+                <icon-trashcan v-if="action.value === 'delete'" />
+              </template>
+            </table-row-action>
+          </template>
+        </b-table>
+      </b-col>
+    </b-row>
+
+    <!-- Modals -->
+    <modal-upload-certificate :certificate="modalCertificate" @ok="onModalOk" />
+  </b-container>
+</template>
+
+<script>
+import IconAdd from '@carbon/icons-vue/es/add--alt/20';
+import IconReplace from '@carbon/icons-vue/es/renew/20';
+import IconTrashcan from '@carbon/icons-vue/es/trash-can/20';
+
+import ModalUploadCertificate from './ModalUploadCertificate';
+import PageTitle from '../../../components/Global/PageTitle';
+import TableRowAction from '../../../components/Global/TableRowAction';
+
+import BVToastMixin from '../../../components/Mixins/BVToastMixin';
+
+export default {
+  name: 'SslCertificates',
+  components: {
+    IconAdd,
+    IconReplace,
+    IconTrashcan,
+    ModalUploadCertificate,
+    PageTitle,
+    TableRowAction
+  },
+  mixins: [BVToastMixin],
+  data() {
+    return {
+      modalCertificate: null,
+      fields: [
+        {
+          key: 'certificate',
+          label: this.$t('pageSslCertificates.table.certificate')
+        },
+        {
+          key: 'issuedBy',
+          label: this.$t('pageSslCertificates.table.issuedBy')
+        },
+        {
+          key: 'issuedTo',
+          label: this.$t('pageSslCertificates.table.issuedTo')
+        },
+        {
+          key: 'validFrom',
+          label: this.$t('pageSslCertificates.table.validFrom')
+        },
+        {
+          key: 'validUntil',
+          label: this.$t('pageSslCertificates.table.validUntil')
+        },
+        {
+          key: 'actions',
+          label: '',
+          tdClass: 'text-right'
+        }
+      ]
+    };
+  },
+  computed: {
+    certificates() {
+      return this.$store.getters['sslCertificates/allCertificates'];
+    },
+    tableItems() {
+      return this.certificates.map(certificate => {
+        return {
+          ...certificate,
+          actions: [
+            {
+              value: 'replace',
+              title: this.$t('pageSslCertificates.replaceCertificate')
+            },
+            {
+              value: 'delete',
+              title: this.$t('pageSslCertificates.deleteCertificate'),
+              enabled:
+                certificate.type === 'TrustStore Certificate' ? true : false
+            }
+          ]
+        };
+      });
+    },
+    certificatesForUpload() {
+      return this.$store.getters['sslCertificates/availableUploadTypes'];
+    }
+  },
+  created() {
+    this.$store.dispatch('sslCertificates/getCertificates');
+  },
+  methods: {
+    onTableRowAction(event, rowItem) {
+      switch (event) {
+        case 'replace':
+          this.initModalUploadCertificate(rowItem);
+          break;
+        case 'delete':
+          this.initModalDeleteCertificate(rowItem);
+          break;
+        default:
+          break;
+      }
+    },
+    initModalUploadCertificate(certificate = null) {
+      this.modalCertificate = certificate;
+      this.$bvModal.show('upload-certificate');
+    },
+    initModalDeleteCertificate(certificate) {
+      this.$bvModal
+        .msgBoxConfirm(
+          this.$t('pageSslCertificates.modal.deleteConfirmMessage', {
+            issuedBy: certificate.issuedBy,
+            certificate: certificate.certificate
+          }),
+          {
+            title: this.$t('pageSslCertificates.deleteCertificate'),
+            okTitle: this.$t('global.action.delete')
+          }
+        )
+        .then(deleteConfirmed => {
+          if (deleteConfirmed) this.deleteCertificate(certificate);
+        });
+    },
+    onModalOk({ addNew, file, type, location }) {
+      if (addNew) {
+        // Upload a new certificate
+        this.addNewCertificate(file, type);
+      } else {
+        // Replace an existing certificate
+        this.replaceCertificate(file, type, location);
+      }
+    },
+    addNewCertificate(file, type) {
+      this.$store
+        .dispatch('sslCertificates/addNewCertificate', { file, type })
+        .then(success => this.successToast(success))
+        .catch(({ message }) => this.errorToast(message));
+    },
+    replaceCertificate(file, type, location) {
+      const reader = new FileReader();
+      reader.readAsBinaryString(file);
+      reader.onloadend = event => {
+        const certificateString = event.target.result;
+        this.$store
+          .dispatch('sslCertificates/replaceCertificate', {
+            certificateString,
+            type,
+            location
+          })
+          .then(success => this.successToast(success))
+          .catch(({ message }) => this.errorToast(message));
+      };
+    },
+    deleteCertificate({ type, location }) {
+      this.$store
+        .dispatch('sslCertificates/deleteCertificate', {
+          type,
+          location
+        })
+        .then(success => this.successToast(success))
+        .catch(({ message }) => this.errorToast(message));
+    }
+  }
+};
+</script>
diff --git a/src/views/AccessControl/SslCertificates/index.js b/src/views/AccessControl/SslCertificates/index.js
new file mode 100644
index 0000000..03daa56
--- /dev/null
+++ b/src/views/AccessControl/SslCertificates/index.js
@@ -0,0 +1,2 @@
+import SslCertificates from './SslCertificates.vue';
+export default SslCertificates;