diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/assets/styles/_form-components.scss | 2 | ||||
-rw-r--r-- | src/components/AppNavigation/AppNavigation.vue | 2 | ||||
-rw-r--r-- | src/locales/en-US.json | 31 | ||||
-rw-r--r-- | src/main.js | 2 | ||||
-rw-r--r-- | src/router/index.js | 8 | ||||
-rw-r--r-- | src/store/api.js | 4 | ||||
-rw-r--r-- | src/store/index.js | 4 | ||||
-rw-r--r-- | src/store/modules/AccessControl/SslCertificatesStore.js | 158 | ||||
-rw-r--r-- | src/views/AccessControl/SslCertificates/ModalUploadCertificate.vue | 164 | ||||
-rw-r--r-- | src/views/AccessControl/SslCertificates/SslCertificates.vue | 209 | ||||
-rw-r--r-- | src/views/AccessControl/SslCertificates/index.js | 2 |
11 files changed, 581 insertions, 5 deletions
diff --git a/src/assets/styles/_form-components.scss b/src/assets/styles/_form-components.scss index d9ae9d40..e7a7b0c9 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 d0fee43f..f2f049b3 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 19b20824..d1d5f615 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 ab1f2967..17fc50fa 100644 --- a/src/main.js +++ b/src/main.js @@ -10,6 +10,7 @@ import { CollapsePlugin, FormPlugin, FormCheckboxPlugin, + FormFilePlugin, FormGroupPlugin, FormInputPlugin, FormRadioPlugin, @@ -64,6 +65,7 @@ Vue.use(BVConfigPlugin, { 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 cd6cf8bf..2af53ea6 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -39,6 +39,14 @@ const routes = [ } }, { + 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 8fdbdd2f..24a38e4b 100644 --- a/src/store/api.js +++ b/src/store/api.js @@ -29,8 +29,8 @@ export default { 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 08ada05e..0180213d 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -4,6 +4,7 @@ import Vuex from 'vuex'; 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 @@ export default new Vuex.Store({ 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 00000000..e1758d3c --- /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 00000000..653a232f --- /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 00000000..ae28271f --- /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 00000000..03daa565 --- /dev/null +++ b/src/views/AccessControl/SslCertificates/index.js @@ -0,0 +1,2 @@ +import SslCertificates from './SslCertificates.vue'; +export default SslCertificates; |