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 <12028987+sureshanapa...@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.davidjumani1...@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() } } }