You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cloudstack.apache.org by ro...@apache.org on 2020/07/04 05:59:20 UTC

[cloudstack-primate] branch master updated: iam: UI changes for Dynamic roles improvements (#353)

This is an automated email from the ASF dual-hosted git repository.

rohit pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cloudstack-primate.git


The following commit(s) were added to refs/heads/master by this push:
     new 6bb8173  iam: UI changes for Dynamic roles improvements (#353)
6bb8173 is described below

commit 6bb8173e8fef491fc0bf04096046f37754de7d6b
Author: sureshanaparti <12...@users.noreply.github.com>
AuthorDate: Sat Jul 4 11:29:11 2020 +0530

    iam: UI changes for Dynamic roles improvements (#353)
    
    This PR addresses the below UI improvements for current Dynamic roles functionality.
    
    Export rules of a role to a CSV file, with name: .csv_
    Import a role with its rules using a CSV file.
    Create a role from any of the existing role.
    
    Co-authored-by: davidjumani <dj...@gmail.com>
---
 src/config/section/role.js          |  16 +-
 src/locales/en.json                 |   7 +
 src/views/iam/CreateRole.vue        | 205 ++++++++++++++++++++++++
 src/views/iam/ImportRole.vue        | 299 ++++++++++++++++++++++++++++++++++++
 src/views/iam/RolePermissionTab.vue |  48 +++++-
 5 files changed, 564 insertions(+), 11 deletions(-)

diff --git a/src/config/section/role.js b/src/config/section/role.js
index 3dccfa0..cefe1e1 100644
--- a/src/config/section/role.js
+++ b/src/config/section/role.js
@@ -36,12 +36,16 @@ export default {
       icon: 'plus',
       label: 'label.add.role',
       listView: true,
-      args: ['name', 'description', 'type'],
-      mapping: {
-        type: {
-          options: ['Admin', 'DomainAdmin', 'User']
-        }
-      }
+      popup: true,
+      component: () => import('@/views/iam/CreateRole.vue')
+    },
+    {
+      api: 'importRole',
+      icon: 'cloud-upload',
+      label: 'label.import.role',
+      listView: true,
+      popup: true,
+      component: () => import('@/views/iam/ImportRole.vue')
     },
     {
       api: 'updateRole',
diff --git a/src/locales/en.json b/src/locales/en.json
index 4b64d55..78ad666 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -449,6 +449,7 @@
 "label.baremetalcpucores": "# of CPU Cores",
 "label.baremetalmac": "Host MAC",
 "label.baremetalmemory": "Memory (in MB)",
+"label.based.on": "Based on",
 "label.basic": "Basic",
 "label.basic.mode": "Basic Mode",
 "label.basicsetup": "Basic setup",
@@ -777,6 +778,9 @@
 "label.enter.token": "Enter token",
 "label.error": "Error",
 "label.error.code": "Error Code",
+"label.error.file.upload": "File upload failed",
+"label.error.file.read": "Cannot read file",
+"label.error.rules.file.import": "Please choose a valid CSV rules file",
 "label.error.something.went.wrong.please.correct.the.following": "Something went wrong; please correct the following",
 "label.error.upper": "ERROR",
 "label.error.volume.upload": "Please choose a file",
@@ -937,6 +941,7 @@
 "label.ikepolicy": "IKE policy",
 "label.images": "Images",
 "label.import.backup.offering": "Import Backup Offering",
+"label.import.role": "Import Role",
 "label.info": "Info",
 "label.info.upper": "INFO",
 "label.infrastructure": "Infrastructure",
@@ -1665,6 +1670,8 @@
 "label.rule": "Rule",
 "label.rule.number": "Rule Number",
 "label.rules": "Rules",
+"label.rules.file": "Rules File",
+"label.rules.file.import.description": "Click or drag rule defintions CVS file to import",
 "label.running": "Running VMs",
 "label.saml.disable": "SAML Disable",
 "label.saml.enable": "SAML Enable",
diff --git a/src/views/iam/CreateRole.vue b/src/views/iam/CreateRole.vue
new file mode 100644
index 0000000..23730b5
--- /dev/null
+++ b/src/views/iam/CreateRole.vue
@@ -0,0 +1,205 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <div class="form-layout">
+    <a-spin :spinning="loading">
+      <a-form
+        :form="form"
+        @submit="handleSubmit"
+        layout="vertical">
+        <a-form-item :label="$t('label.name')">
+          <a-input
+            v-decorator="['name', {
+              rules: [{ required: true, message: $t('message.error.required.input') }]
+            }]"
+            :placeholder="createRoleApiParams.name.description" />
+        </a-form-item>
+
+        <a-form-item :label="$t('label.description')">
+          <a-input
+            v-decorator="['description']"
+            :placeholder="createRoleApiParams.description.description" />
+        </a-form-item>
+
+        <a-form-item :label="$t('label.based.on')" v-if="'roleid' in createRoleApiParams">
+          <a-radio-group
+            v-decorator="['using', {
+              initialValue: this.createRoleUsing
+            }]"
+            buttonStyle="solid"
+            @change="selected => { this.handleChangeCreateRole(selected.target.value) }">
+            <a-radio-button value="type">
+              {{ $t('label.type') }}
+            </a-radio-button>
+            <a-radio-button value="role">
+              {{ $t('label.role') }}
+            </a-radio-button>
+          </a-radio-group>
+        </a-form-item>
+
+        <a-form-item :label="$t('label.type')" v-if="this.createRoleUsing === 'type'">
+          <a-select
+            v-decorator="['type', {
+              rules: [{ required: true, message: $t('message.error.select') }]
+            }]"
+            :placeholder="createRoleApiParams.type.description">
+            <a-select-option v-for="role in defaultRoles" :key="role">
+              {{ role }}
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+
+        <a-form-item :label="$t('label.role')" v-if="this.createRoleUsing === 'role'">
+          <a-select
+            v-decorator="['roleid', {
+              rules: [{ required: true, message: $t('message.error.select') }]
+            }]"
+            :placeholder="createRoleApiParams.roleid.description">
+            <a-select-option
+              v-for="role in roles"
+              :value="role.id"
+              :key="role.id">
+              {{ role.name }}
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+
+        <div :span="24" class="action-button">
+          <a-button @click="closeAction">{{ this.$t('label.cancel') }}</a-button>
+          <a-button :loading="loading" type="primary" @click="handleSubmit">{{ this.$t('label.ok') }}</a-button>
+        </div>
+      </a-form>
+    </a-spin>
+  </div>
+</template>
+
+<script>
+import { api } from '@/api'
+
+export default {
+  name: 'CreateRole',
+  data () {
+    return {
+      roles: [],
+      defaultRoles: ['Admin', 'DomainAdmin', 'ResourceAdmin', 'User'],
+      createRoleUsing: 'type',
+      loading: false
+    }
+  },
+  mounted () {
+    this.fetchRoles()
+  },
+  beforeCreate () {
+    this.form = this.$form.createForm(this)
+    this.apiConfig = this.$store.getters.apis.createRole || {}
+    this.createRoleApiParams = {}
+    this.apiConfig.params.forEach(param => {
+      this.createRoleApiParams[param.name] = param
+    })
+  },
+  watch: {
+    '$route' (to, from) {
+      if (to.fullPath !== from.fullPath && !to.fullPath.includes('action/')) {
+        this.fetchRoles()
+      }
+    },
+    '$i18n.locale' (to, from) {
+      if (to !== from) {
+        this.fetchRoles()
+      }
+    }
+  },
+  methods: {
+    handleSubmit (e) {
+      e.preventDefault()
+      this.form.validateFields((err, values) => {
+        if (err) {
+          return
+        }
+        const params = {}
+        for (const key in values) {
+          if (key === 'using') {
+            continue
+          }
+
+          const input = values[key]
+          if (input === undefined) {
+            continue
+          }
+
+          params[key] = input
+        }
+
+        this.createRole(params)
+      })
+    },
+    closeAction () {
+      this.$emit('close-action')
+    },
+    createRole (params) {
+      this.loading = true
+      api('createRole', params).then(json => {
+        const role = json.createroleresponse.role
+        if (role) {
+          this.$emit('refresh-data')
+          this.$notification.success({
+            message: 'Create Role',
+            description: 'Sucessfully created role ' + params.name
+          })
+        }
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.loading = false
+        this.closeAction()
+      })
+    },
+    fetchRoles () {
+      const params = {}
+      api('listRoles', params).then(json => {
+        if (json && json.listrolesresponse && json.listrolesresponse.role) {
+          this.roles = json.listrolesresponse.role
+        }
+      }).catch(error => {
+        console.error(error)
+      })
+    },
+    handleChangeCreateRole (value) {
+      this.createRoleUsing = value
+    }
+  }
+}
+</script>
+
+<style scoped lang="less">
+  .form-layout {
+    width: 80vw;
+
+    @media (min-width: 700px) {
+      width: 550px;
+    }
+  }
+
+  .action-button {
+    text-align: right;
+
+    button {
+      margin-right: 5px;
+    }
+  }
+</style>
diff --git a/src/views/iam/ImportRole.vue b/src/views/iam/ImportRole.vue
new file mode 100644
index 0000000..64c6ee5
--- /dev/null
+++ b/src/views/iam/ImportRole.vue
@@ -0,0 +1,299 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <div class="form-layout">
+    <a-spin :spinning="loading">
+      <a-form
+        :form="form"
+        @submit="handleSubmit"
+        layout="vertical">
+        <a-form-item :label="$t('label.rules.file')">
+          <a-upload-dragger
+            :multiple="false"
+            :fileList="fileList"
+            :remove="handleRemove"
+            :beforeUpload="beforeUpload"
+            @change="handleChange"
+            v-decorator="['file', {
+              rules: [{ required: true, message: $t('message.error.required.input') },
+                      {
+                        validator: checkCsvRulesFile,
+                        message: $t('label.error.rules.file.import')
+                      }
+              ]
+            }]">
+            <p class="ant-upload-drag-icon">
+              <a-icon type="cloud-upload" />
+            </p>
+            <p class="ant-upload-text" v-if="fileList.length === 0">
+              {{ $t('label.rules.file.import.description') }}
+            </p>
+          </a-upload-dragger>
+        </a-form-item>
+        <a-form-item :label="$t('label.name')">
+          <a-input
+            v-decorator="['name', {
+              rules: [{ required: true, message: $t('message.error.required.input') }]
+            }]"
+            :placeholder="importRoleApiParams.name.description" />
+        </a-form-item>
+
+        <a-form-item :label="$t('label.description')">
+          <a-input
+            v-decorator="['description']"
+            :placeholder="importRoleApiParams.description.description" />
+        </a-form-item>
+
+        <a-form-item :label="$t('label.type')">
+          <a-select
+            v-decorator="['type', {
+              rules: [{ required: true, message: $t('message.error.select') }]
+            }]"
+            :placeholder="importRoleApiParams.type.description">
+            <a-select-option v-for="role in defaultRoles" :key="role">
+              {{ role }}
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+
+        <a-form-item :label="$t('label.forced')">
+          <a-switch
+            v-decorator="['forced', {
+              initialValue: false
+            }]" />
+        </a-form-item>
+
+        <div :span="24" class="action-button">
+          <a-button @click="closeAction">{{ this.$t('label.cancel') }}</a-button>
+          <a-button :loading="loading" type="primary" @click="handleSubmit">{{ this.$t('label.ok') }}</a-button>
+        </div>
+      </a-form>
+    </a-spin>
+  </div>
+</template>
+
+<script>
+import { api } from '@/api'
+
+export default {
+  name: 'ImportRole',
+  data () {
+    return {
+      fileList: [],
+      defaultRoles: ['Admin', 'DomainAdmin', 'ResourceAdmin', 'User'],
+      rulesCsv: '',
+      loading: false
+    }
+  },
+  beforeCreate () {
+    this.form = this.$form.createForm(this)
+    this.apiConfig = this.$store.getters.apis.importRole || {}
+    this.importRoleApiParams = {}
+    this.apiConfig.params.forEach(param => {
+      this.importRoleApiParams[param.name] = param
+    })
+  },
+  methods: {
+    handleRemove (file) {
+      const index = this.fileList.indexOf(file)
+      const newFileList = this.fileList.slice()
+      newFileList.splice(index, 1)
+      this.fileList = newFileList
+    },
+    handleChange (info) {
+      if (info.file.status === 'error') {
+        this.$notification.error({
+          message: this.$t('label.error.file.upload'),
+          description: this.$t('label.error.file.upload')
+        })
+      }
+    },
+    beforeUpload (file) {
+      if (file.type !== 'text/csv') {
+        return false
+      }
+
+      this.fileList = [file]
+      return false
+    },
+    handleSubmit (e) {
+      e.preventDefault()
+      this.form.validateFields((err, values) => {
+        if (err) {
+          return
+        }
+        const params = {}
+        for (const key in values) {
+          const input = values[key]
+          if (input === undefined) {
+            continue
+          }
+          if (key === 'file') {
+            continue
+          }
+
+          params[key] = input
+        }
+
+        if (this.fileList.length !== 1) {
+          return
+        }
+
+        var rules = this.rulesCsvToJson(this.rulesCsv)
+        rules.forEach(function (values, index) {
+          for (const key in values) {
+            params['rules[' + index + '].' + key] = values[key]
+          }
+        })
+
+        this.importRole(params)
+      })
+    },
+    closeAction () {
+      this.$emit('close-action')
+    },
+    importRole (params) {
+      this.loading = true
+      api('importRole', {}, 'POST', params).then(json => {
+        const role = json.importroleresponse.role
+        if (role) {
+          this.$emit('refresh-data')
+          this.$notification.success({
+            message: 'Import Role',
+            description: 'Sucessfully imported role ' + params.name
+          })
+        }
+      }).catch(error => {
+        this.$notifyError(error)
+      }).finally(() => {
+        this.loading = false
+        this.closeAction()
+      })
+    },
+    rulesCsvToJson (rulesCsv) {
+      const columnDelimiter = ','
+      const lineDelimiter = '\n'
+      var lines = rulesCsv.split(lineDelimiter)
+      var result = []
+      if (lines.length === 0) {
+        return result
+      }
+      var headers = lines[0].split(columnDelimiter)
+      lines = lines.slice(1) // Remove header
+
+      lines.map((line, indexLine) => {
+        if (line.trim() === '') return // Empty line
+        var obj = {}
+        var currentline = line.trim().split(columnDelimiter)
+
+        headers.map((header, indexHeader) => {
+          if (indexHeader === 2 && currentline.length > 3) {
+            if (currentline[indexHeader].startsWith('"')) {
+              obj[header.trim()] = currentline[indexHeader].substr(1)
+            } else {
+              obj[header.trim()] = currentline[indexHeader]
+            }
+
+            for (let i = 3; i < currentline.length - 1; i++) {
+              obj[header.trim()] += columnDelimiter + currentline[i]
+            }
+
+            var lastColumn = currentline[currentline.length - 1]
+            if (lastColumn.endsWith('"')) {
+              obj[header.trim()] += columnDelimiter + lastColumn.substr(0, lastColumn.length - 1)
+            } else {
+              obj[header.trim()] += columnDelimiter + lastColumn
+            }
+          } else {
+            obj[header.trim()] = currentline[indexHeader]
+          }
+        })
+        result.push(obj)
+      })
+      return result
+    },
+    checkCsvRulesFile (rule, value, callback) {
+      if (!value || value === '' || value.file === '') {
+        callback()
+      } else {
+        if (value.file.type !== 'text/csv') {
+          callback(rule.message)
+        }
+
+        this.readCsvFile(value.file).then((validFile) => {
+          if (!validFile) {
+            callback(rule.message)
+          } else {
+            callback()
+          }
+        }).catch((reason) => {
+          console.log(reason)
+          callback(rule.message)
+        })
+      }
+    },
+    readCsvFile (file) {
+      return new Promise((resolve, reject) => {
+        if (window.FileReader) {
+          var reader = new FileReader()
+          reader.onload = (event) => {
+            this.rulesCsv = event.target.result
+            var lines = this.rulesCsv.split('\n')
+            var headers = lines[0].split(',')
+            if (headers.length !== 3) {
+              resolve(false)
+            } else if (!(headers[0].trim() === 'rule' && headers[1].trim() === 'permission' && headers[2].trim() === 'description')) {
+              resolve(false)
+            } else {
+              resolve(true)
+            }
+          }
+
+          reader.onerror = (event) => {
+            if (event.target.error.name === 'NotReadableError') {
+              reject(event.target.error)
+            }
+          }
+
+          reader.readAsText(file)
+        } else {
+          reject(this.$t('label.error.file.read'))
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped lang="less">
+  .form-layout {
+    width: 80vw;
+
+    @media (min-width: 700px) {
+      width: 550px;
+    }
+  }
+
+  .action-button {
+    text-align: right;
+
+    button {
+      margin-right: 5px;
+    }
+  }
+</style>
diff --git a/src/views/iam/RolePermissionTab.vue b/src/views/iam/RolePermissionTab.vue
index f95c0d6..bb63b9b 100644
--- a/src/views/iam/RolePermissionTab.vue
+++ b/src/views/iam/RolePermissionTab.vue
@@ -18,6 +18,11 @@
 <template>
   <a-icon v-if="loadingTable" type="loading" class="main-loading-spinner"></a-icon>
   <div v-else>
+    <div style="width: 100%; display: flex; margin-bottom: 10px">
+      <a-button type="dashed" @click="exportRolePermissions" style="width: 100%" icon="download">
+        Export Rules
+      </a-button>
+    </div>
     <div v-if="updateTable" class="loading-overlay">
       <a-icon type="loading" />
     </div>
@@ -166,7 +171,7 @@ export default {
       api('listRolePermissions', { roleid: this.resource.id }).then(response => {
         this.rules = response.listrolepermissionsresponse.rolepermission
       }).catch(error => {
-        console.error(error)
+        this.$notifyError(error)
       }).finally(() => {
         this.loadingTable = false
         this.updateTable = false
@@ -178,7 +183,7 @@ export default {
         roleid: this.resource.id,
         ruleorder: this.rules.map(rule => rule.id)
       }).catch(error => {
-        console.error(error)
+        this.$notifyError(error)
       }).finally(() => {
         this.fetchData()
       })
@@ -186,7 +191,7 @@ export default {
     onRuleDelete (key) {
       this.updateTable = true
       api('deleteRolePermission', { id: key }).catch(error => {
-        console.error(error)
+        this.$notifyError(error)
       }).finally(() => {
         this.fetchData()
       })
@@ -204,7 +209,7 @@ export default {
       }).then(() => {
         this.fetchData()
       }).catch(error => {
-        console.error(error)
+        this.$notifyError(error)
       })
     },
     onRuleSelect (value) {
@@ -224,11 +229,44 @@ export default {
         roleid: this.resource.id
       }).then(() => {
       }).catch(error => {
-        console.error(error)
+        this.$notifyError(error)
       }).finally(() => {
         this.resetNewFields()
         this.fetchData()
       })
+    },
+    rulesDataToCsv ({ data = null, columnDelimiter = ',', lineDelimiter = '\n' }) {
+      if (data === null || !data.length) {
+        return null
+      }
+
+      const keys = ['rule', 'permission', 'description']
+      let result = ''
+      result += keys.join(columnDelimiter)
+      result += lineDelimiter
+
+      data.forEach(item => {
+        keys.forEach(key => {
+          if (item[key] === undefined) {
+            item[key] = ''
+          }
+          result += typeof item[key] === 'string' && item[key].includes(columnDelimiter) ? `"${item[key]}"` : item[key]
+          result += columnDelimiter
+        })
+        result = result.slice(0, -1)
+        result += lineDelimiter
+      })
+
+      return result
+    },
+    exportRolePermissions () {
+      const rulesCsvData = this.rulesDataToCsv({ data: this.rules })
+      const hiddenElement = document.createElement('a')
+      hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(rulesCsvData)
+      hiddenElement.target = '_blank'
+      hiddenElement.download = this.resource.name + '_' + this.resource.type + '.csv'
+      hiddenElement.click()
+      hiddenElement.delete()
     }
   }
 }