Copilot commented on code in PR #13225: URL: https://github.com/apache/cloudstack/pull/13225#discussion_r3302445732
########## ui/src/views/iam/GenerateApiKeyPair.vue: ########## @@ -0,0 +1,229 @@ +// 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" v-ctrl-enter="handleSubmit"> + <a-modal + v-if="showAddKeyPair" + :visible="showAddKeyPair" + :closable="true" + :maskClosable="false" + :okText="$t('label.ok')" + :cancelText="$t('label.cancel')" + style="top: 20px;" + width="50vw" + @cancel="closeModal" + @ok="handleSubmit" + :ok-button-props="{props: { type: 'default' } }" + :cancel-button-props="{props: { type: 'primary' } }" + centered> + <template #title> + {{ $t('label.action.create.api.key') }} + </template> + <a-spin :spinning="loading"> + <a-form + :ref="formRef" + :model="form" + layout="vertical" + @finish="handleSubmit"> + <a-alert + style="margin-bottom: 10px; " + :message="$t('message.note.about.keypair.permissions.title')" + :description="$t('message.note.about.keypair.permissions.body')" + type="info" + show-icon + /> + <a-form-item name="name" ref="name"> + <template #label> + <tooltip-label :title="$t('label.name')" :tooltip="apiParams.name.description"/> + </template> + <a-input + v-focus="true" + :placeholder="$t('label.apikeypair.name')" + v-model:value="form.name" /> + </a-form-item> + <a-form-item name="description" ref="description"> + <template #label> + <tooltip-label :title="$t('label.description')" :tooltip="apiParams.description.description"/> + </template> + <a-input + v-model:value="form.description" + :placeholder="$t('label.apikeypair.description')" /> + </a-form-item> + <a-row> + <a-form-item ref="startDate" name="startDate"> + <template #label> + <tooltip-label :title="$t('label.start.date')" :tooltip="apiParams.startdate.description"/> + </template> + <a-date-picker + v-model:value="form.startDate" + :disabled-date="disabledStartDate" + show-time + /> + </a-form-item> + <a-form-item ref="endDate" name="endDate" style="margin: 0 8px"> + <template #label> + <tooltip-label :title="$t('label.end.date')" :tooltip="apiParams.enddate.description"/> + </template> + <a-date-picker + :disabled-date="disabledEndDate" + v-model:value="form.endDate" + show-time /> + </a-form-item> + </a-row> + <a-form-item> + <template #label> + <tooltip-label :title="$t('label.rules')" :tooltip="apiParams.rules.description"/> + </template> + <api-key-pair-permission-table + :resource="resource" + @update-rules="updateRules"/> + </a-form-item> + </a-form> + </a-spin> + </a-modal> + </div> +</template> + +<script> +import { ref, reactive, toRaw } from 'vue' +import { postAPI } from '@/api' +import TooltipLabel from '@/components/widgets/TooltipLabel' +import ApiKeyPairPermissionTable from '@/views/iam/ApiKeyPairPermissionTable.vue' +import { dayjs, parseDayJsObject } from '@/utils/date' + +export default { + name: 'GenerateApiKeyPair', + components: { + TooltipLabel, + ApiKeyPairPermissionTable + }, + props: { + showAddKeyPair: { + type: Boolean, + default: false + }, + resource: { + type: Object, + required: true + } + }, + data () { + return { + rules: [], + loading: false + } + }, + beforeCreate () { + this.apiParams = this.$getApiParams('registerUserKeys') + }, + created () { + this.initForm() + }, + methods: { + initForm () { + this.formRef = ref() + this.form = reactive({}) + }, + isValidValueForKey (obj, key) { + return key in obj && obj[key] != null + }, + buildRequestParams () { + const values = toRaw(this.form) + this.loading = true + const params = { + name: values.name, + id: this.resource.id, + description: values.description ? values.description : null, + startdate: values.startDate ? parseDayJsObject({ value: values.startDate }) : null, + endDate: values.endDate ? parseDayJsObject({ value: values.endDate }) : null + } + for (const i in this.rules) { + const rule = this.rules[i] + params['rules[' + i + '].rule'] = rule.rule ? rule.rule : '' + params['rules[' + i + '].permission'] = rule.permission ? rule.permission : 'deny' + params['rules[' + i + '].description'] = rule.description ? rule.description : '' + } + return params + }, + handleSubmit (e) { + e.preventDefault() Review Comment: `handleSubmit` unconditionally calls `e.preventDefault()`, but this handler is also used for `<a-form @finish>` where Ant Design Vue passes form values (not an event). This will throw at runtime; guard for missing `preventDefault` or split modal-ok vs form-finish handlers. ########## ui/src/views/iam/ApiKeyPairPermissionTable.vue: ########## @@ -0,0 +1,522 @@ +// 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> + <loading-outlined v-if="loadingTable" class="main-loading-spinner" /> + <div v-else> + <div v-if="!disabled" class="rules-list ant-list ant-list-bordered"> + <div class="rules-table-item ant-list-item"> + <div class="rules-table__col rules-table__col--grab" /> + <div class="rules-table__col rules-table__col--rule rules-table__col--new"> + <a-auto-complete + :key="autocompleteKey" + v-focus="true" + :filterOption="filterOption" + :options="apis" + v-model:value="newRule" + :placeholder="$t('label.rule')" + :class="{'rule-dropdown-error' : newRuleSelectError}" /> + </div> + <div class="rules-table__col rules-table__col--permission"> + <permission-editable + :defaultValue="newRulePermission" Review Comment: `PermissionEditable` declares a required `defaultValue` prop, but the template passes `:defaultValue` (camelCase) which will be lowercased by HTML and not match the prop. Use kebab-case `:default-value` so the initial permission value renders correctly. ########## ui/src/components/view/ApiKeyPairsTab.vue: ########## @@ -0,0 +1,455 @@ +// 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> + <a-spin :spinning="fetchLoading"> + <a-button + v-if="'registerUserKeys' in $store.getters.apis" + type="dashed" + style="width: 100%; margin-bottom: 15px" + @click="onShowAddKeyPair()"> + <template #icon><plus-outlined /></template> + {{ $t('label.register.api.key') }} + </a-button> + <a-button + v-if="this.selectedRowKeys.length > 0 && ('deleteUserKeys' in $store.getters.apis)" + type="primary" + danger + style="width: 100%; margin-bottom: 15px" + @click="bulkActionConfirmation()"> + <template #icon><delete-outlined /></template> + {{ $t('label.action.bulk.delete.api.keys') }} + </a-button> + <a-table + size="small" + style="overflow-y: auto" + :columns="columns" + :dataSource="keypairs" + :rowKey="item => item.id" + :key="item => item.id" + :rowSelection="rowSelection()" + :pagination="false" > + <template #name="{ record }"> + <div> + <router-link :to="{ path: '/keypair/' + record.id }" > + {{ record.name }} + </router-link> + </div> + </template> + <template #apikey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.apikey" /> + </strong> + <div> + {{ record.apikey.substring(0, 20) }}... + </div> + </template> + + <template #secretkey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.secretkey" /> + </strong> + <div> + {{ record.secretkey.substring(0, 20) }}... + </div> + </template> + + <template #startdate="{ record }"> + <div> {{ $toLocaleDate(record.startdate) }} </div> + </template> + + <template #enddate="{ record }"> + <div> {{ $toLocaleDate(record.enddate)}} </div> + </template> + + <template #created="{ record }"> + <div> {{ $toLocaleDate(record.created) }} </div> + </template> + + </a-table> + <a-divider/> + <a-pagination + class="row-element pagination" + size="small" + :current="page" + :pageSize="pageSize" + :total="totalKeypairs" + :showTotal="total => `${$t('label.total')} ${total} ${$t('label.items')}`" + :pageSizeOptions="['10', '20', '40', '80', '100']" + @change="changePage" + @showSizeChange="changePageSize" + showSizeChanger> + <template #buildOptionText="props"> + <span>{{ props.value }} / {{ $t('label.page') }}</span> + </template> + </a-pagination> + </a-spin> + <bulk-action-view + v-if="(showConfirmationAction || showGroupActionModal)" + :showConfirmationAction="showConfirmationAction" + :showGroupActionModal="showGroupActionModal" + :items="keypairs" + :selectedRowKeys="selectedRowKeys" + :selectedItems="selectedItems" + :columns="columns" + :selectedColumns="selectedColumns" + action="eraseKeypairs" + :loading="loading" + :message="bulkDeleteMessage" + @group-action="eraseKeypairs" + @handle-cancel="handleCancelBulk" + @close-modal="closeModalBulk" /> + <generate-api-key-pair + :showAddKeyPair="showAddKeyPair" + :resource="resource" + @fetch-data="fetchData" + @handle-cancel="handleCancelAddKeyPair" + @refresh-data="handleRefreshData" + @close-modal="closeModalAddKeyPair" /> + </div> +</template> +<script> +import { getAPI, postAPI } from '@/api' +import Status from '@/components/widgets/Status' +import TooltipButton from '@/components/widgets/TooltipButton' +import BulkActionView from '@/components/view/BulkActionView.vue' +import eventBus from '@/config/eventBus' +import OwnershipSelection from '@/views/compute/wizard/OwnershipSelection.vue' +import GenerateApiKeyPair from '@/views/iam/GenerateApiKeyPair.vue' +import store from '@/store' + +export default { + name: 'ApiKeyPairsTab', + components: { + OwnershipSelection, + Status, + TooltipButton, + BulkActionView, + GenerateApiKeyPair, + store + }, + props: { + resource: { + type: Object, + required: true + }, + loading: { + type: Boolean, + default: false + } + }, + data () { + return { + fetchLoading: false, + keypairs: [], + page: 1, + pageSize: 10, + totalKeypairs: 0, + selectedRowKeys: [], + selectedItems: [], + selectedColumns: [], + filterColumns: ['Action'], + showConfirmationAction: false, + showAddKeyPair: false, + showGroupActionModal: false, + bulkDeleteMessage: { + title: this.$t('label.action.bulk.delete.api.keys'), + confirmMessage: this.$t('label.confirm.delete.api.keys') + }, + columns: [ + { + title: this.$t('label.name'), + dataIndex: 'name', + slots: { customRender: 'name' } + }, + { + title: this.$t('label.apikey'), + dataIndex: 'apikey', + slots: { customRender: 'apikey' } + }, + { + title: this.$t('label.secretkey'), + dataIndex: 'secretkey', + slots: { customRender: 'secretkey' } + }, + { + title: this.$t('label.start.date'), + dataIndex: 'startdate', + slots: { customRender: 'startdate' } + }, + { + title: this.$t('label.end.date'), + dataIndex: 'enddate', + slots: { customRender: 'enddate' } + }, + { + title: this.$t('label.created'), + dataIndex: 'created', + slots: { customRender: 'created' } + } + ] + } + }, + created () { + this.fetchData() + }, + watch: { + resource: { + deep: true, + handler (newItem) { + if (!newItem || !newItem.id) { + return + } + this.fetchData() + } + } + }, + inject: ['parentFetchData'], + methods: { + fetchData () { + const params = { + listall: true, + page: this.page, + pagesize: this.pageSize, + userid: this.resource.id + } + this.fetchLoading = true + getAPI('listUserKeys', params).then(json => { + this.totalKeypairs = json.listuserkeysresponse.count || 0 + this.keypairs = json.listuserkeysresponse.userapikey || [] + }).finally(() => { + this.fetchLoading = false + }) + }, + setSelection (selection) { + this.selectedRowKeys = selection + this.$emit('selection-change', this.selectedRowKeys) + this.selectedItems = (this.keypairs.filter(function (item) { + return selection.indexOf(item.id) !== -1 + })) + }, + changePage (page, pageSize) { + this.page = page + this.pageSize = pageSize + this.fetchData() + }, + changePageSize (currentPage, pageSize) { + this.page = currentPage + this.pageSize = pageSize + this.fetchData() + }, + onShowAddKeyPair () { + this.showAddKeyPair = true + }, + eraseKeypairs () { + this.selectedColumns.splice(0, 0, { + dataIndex: 'status', + title: this.$t('label.operation.status'), + slots: { customRender: 'status' }, + filters: [ + { text: 'In Progress', value: 'InProgress' }, + { text: 'Success', value: 'success' }, + { text: 'Failed', value: 'failed' } + ] + }) + if (this.selectedRowKeys.length > 0) { + this.showGroupActionModal = true + } + this.deleteKeypairs(this.selectedItems) + }, + deleteKeypairs (keypairs) { Review Comment: When `keypairs` is empty, `deleteKeypairs` sets `fetchLoading = true` and the `forEach` body never runs, leaving the spinner stuck. Add an early return/reset when there are no items to delete. ########## ui/public/locales/pt_BR.json: ########## @@ -2046,6 +2058,7 @@ "label.secondarystoragelimit": "Limites do armazenamento secund\u00e1rio (GiB)", "label.secretkey": "Chave secreta", "label.secret.key": "Chave secreta", +"label.apikeyaccess": "Accesso a pares de chaves de API", Review Comment: Typo in Portuguese translation: "Accesso" should be "Acesso". ########## ui/src/views/iam/ApiKeyPairPermissionTable.vue: ########## @@ -0,0 +1,522 @@ +// 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> + <loading-outlined v-if="loadingTable" class="main-loading-spinner" /> + <div v-else> + <div v-if="!disabled" class="rules-list ant-list ant-list-bordered"> + <div class="rules-table-item ant-list-item"> + <div class="rules-table__col rules-table__col--grab" /> + <div class="rules-table__col rules-table__col--rule rules-table__col--new"> + <a-auto-complete + :key="autocompleteKey" + v-focus="true" + :filterOption="filterOption" + :options="apis" + v-model:value="newRule" + :placeholder="$t('label.rule')" + :class="{'rule-dropdown-error' : newRuleSelectError}" /> + </div> + <div class="rules-table__col rules-table__col--permission"> + <permission-editable + :defaultValue="newRulePermission" + :value="newRulePermission" + @onChange="updateNewPermission()" /> + </div> + <div class="rules-table__col rules-table__col--description"> + <a-input v-model:value="newRuleDescription" :placeholder="$t('label.description')" /> + </div> + <div class="rules-table__col rules-table__col--actions"> + <tooltip-button + tooltipPlacement="bottom" + :tooltip="$t('label.save.new.rule')" + icon="plus-outlined" + type="primary" + @onClick="onRuleSave" /> + </div> + </div> + + <draggable + v-model="rules" + @change="updateRules" + handle=".drag-handle" + ghostClass="drag-ghost" + :component-data="{type: 'transition'}" + item-key="rule"> + <template #item="{element, index}"> + <div class="rules-table-item ant-list-item"> + <div class="rules-table__col rules-table__col--grab drag-handle"> + <drag-outlined /> + </div> + <div class="rules-table__col rules-table__col--rule"> + {{ element.rule }} + </div> + <div class="rules-table__col rules-table__col--permission"> + <permission-editable + :default-value="element.permission" + @onChange="onPermissionChange(element, $event, index)" /> + </div> + <div class="rules-table__col rules-table__col--description"> + <div v-if="element.description"> + {{ element.description }} + </div> + <div v-else class="no-description"> + {{ $t('message.no.description') }} + </div> + </div> + <div class="rules-table__col rules-table__col--actions"> + <tooltip-button + :tooltip="$t('label.delete.rule')" + tooltipPlacement="bottom" + type="primary" + :danger="true" + icon="delete-outlined" + :disabled="false" + @onClick="onRuleDelete(element.rule, index)" /> + </div> + </div> + </template> + </draggable> + </div> + + <div :style="{width: '100%', display: 'flex', marginTop: this.rules.length > 0 ? '12px' : '0'}" v-if="this.rules.length > 0 && !disabled"> + <a-button + style="width: 100%;" + danger + @click="deleteAllRules()"> + <template #icon><delete-outlined /></template> + {{ $t('label.delete.all.rules') }} + </a-button> + </div> + + <a-table + v-else-if="disabled" + :columns="columns" + :dataSource="rules" + rowKey="rule" + size="large" + :pagination="pagination" + @change="handlePaginationChange"> + <template #customFilterDropdown="{ setSelectedKeys, selectedKeys, confirm, clearFilters, column }"> + <div style="padding: 8px"> + <a-input + ref="searchInput" + :placeholder="$t('label.search')" + :value="selectedKeys[0]" + style="width: 100%; margin-bottom: 8px; display: block" + @change="e => setSelectedKeys(e.target.value ? [e.target.value] : [])" + @pressEnter="handleSearch(selectedKeys, confirm, column.dataIndex)" + /> + <div style="display: flex; gap: 8px"> + <a-button + type="primary" + size="small" + style="width: 112px;" + @click="handleSearch(selectedKeys, confirm, column.dataIndex)"> + <template #icon> + <search-outlined /> + </template> + {{ $t('label.search') }} + </a-button> + + <a-button + size="small" + style="width: 112px;" + @click="handleReset(clearFilters)"> + {{ $t('label.reset') }} + </a-button> + </div> + </div> + </template> + + <template #customFilterIcon="{ filtered }"> + <search-outlined :style="{ color: filtered ? '#1890ff' : '', fontSize: '14px' }" /> + </template> + + <template #bodyCell="{ column, record }"> + <template v-if="column.key === 'permission'"> + <a-tag + class="permission-tag" + :style="{ + backgroundColor: record.permission === 'allow' ? '#d9f7be' : '#fff2f0', + color: record.permission === 'allow' ? '#135200' : '#cf1322' + }"> + <check-outlined v-if="record.permission === 'allow'" /> + <close-outlined v-else /> + {{ record.permission === 'allow' ? $t('label.allow') : $t('label.deny') }} + </a-tag> + </template> + + <template v-else-if="column.key === 'description' && record.description"> + {{ record.description }} + </template> + </template> + </a-table> + </div> +</template> + +<script> +import { getAPI } from '@/api' +import draggable from 'vuedraggable' +import PermissionEditable from './PermissionEditable' +import RuleDelete from './RuleDelete' +import TooltipButton from '@/components/widgets/TooltipButton' +import { genericCompare } from '@/utils/sort' + +export default { + name: 'ApiKeyPairPermissionTable', + components: { + RuleDelete, + PermissionEditable, + draggable, + TooltipButton Review Comment: `RuleDelete` is imported/registered but never used in the template. This is dead code and may fail linting; remove the unused import/component registration or use it in the template. ########## ui/src/views/iam/GenerateApiKeyPair.vue: ########## @@ -0,0 +1,229 @@ +// 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" v-ctrl-enter="handleSubmit"> + <a-modal + v-if="showAddKeyPair" + :visible="showAddKeyPair" + :closable="true" + :maskClosable="false" + :okText="$t('label.ok')" + :cancelText="$t('label.cancel')" + style="top: 20px;" + width="50vw" + @cancel="closeModal" + @ok="handleSubmit" + :ok-button-props="{props: { type: 'default' } }" + :cancel-button-props="{props: { type: 'primary' } }" + centered> + <template #title> + {{ $t('label.action.create.api.key') }} + </template> + <a-spin :spinning="loading"> + <a-form + :ref="formRef" + :model="form" + layout="vertical" + @finish="handleSubmit"> + <a-alert + style="margin-bottom: 10px; " + :message="$t('message.note.about.keypair.permissions.title')" + :description="$t('message.note.about.keypair.permissions.body')" + type="info" + show-icon + /> + <a-form-item name="name" ref="name"> + <template #label> + <tooltip-label :title="$t('label.name')" :tooltip="apiParams.name.description"/> + </template> + <a-input + v-focus="true" + :placeholder="$t('label.apikeypair.name')" + v-model:value="form.name" /> + </a-form-item> + <a-form-item name="description" ref="description"> + <template #label> + <tooltip-label :title="$t('label.description')" :tooltip="apiParams.description.description"/> + </template> + <a-input + v-model:value="form.description" + :placeholder="$t('label.apikeypair.description')" /> + </a-form-item> + <a-row> + <a-form-item ref="startDate" name="startDate"> + <template #label> + <tooltip-label :title="$t('label.start.date')" :tooltip="apiParams.startdate.description"/> + </template> + <a-date-picker + v-model:value="form.startDate" + :disabled-date="disabledStartDate" + show-time + /> + </a-form-item> + <a-form-item ref="endDate" name="endDate" style="margin: 0 8px"> + <template #label> + <tooltip-label :title="$t('label.end.date')" :tooltip="apiParams.enddate.description"/> + </template> + <a-date-picker + :disabled-date="disabledEndDate" + v-model:value="form.endDate" + show-time /> + </a-form-item> + </a-row> + <a-form-item> + <template #label> + <tooltip-label :title="$t('label.rules')" :tooltip="apiParams.rules.description"/> + </template> + <api-key-pair-permission-table + :resource="resource" + @update-rules="updateRules"/> + </a-form-item> + </a-form> + </a-spin> + </a-modal> + </div> +</template> + +<script> +import { ref, reactive, toRaw } from 'vue' +import { postAPI } from '@/api' +import TooltipLabel from '@/components/widgets/TooltipLabel' +import ApiKeyPairPermissionTable from '@/views/iam/ApiKeyPairPermissionTable.vue' +import { dayjs, parseDayJsObject } from '@/utils/date' + +export default { + name: 'GenerateApiKeyPair', + components: { + TooltipLabel, + ApiKeyPairPermissionTable + }, + props: { + showAddKeyPair: { + type: Boolean, + default: false + }, + resource: { + type: Object, + required: true + } + }, + data () { + return { + rules: [], + loading: false + } + }, + beforeCreate () { + this.apiParams = this.$getApiParams('registerUserKeys') + }, + created () { + this.initForm() + }, + methods: { + initForm () { + this.formRef = ref() + this.form = reactive({}) + }, + isValidValueForKey (obj, key) { + return key in obj && obj[key] != null + }, + buildRequestParams () { + const values = toRaw(this.form) + this.loading = true + const params = { + name: values.name, + id: this.resource.id, + description: values.description ? values.description : null, + startdate: values.startDate ? parseDayJsObject({ value: values.startDate }) : null, + endDate: values.endDate ? parseDayJsObject({ value: values.endDate }) : null + } + for (const i in this.rules) { + const rule = this.rules[i] + params['rules[' + i + '].rule'] = rule.rule ? rule.rule : '' + params['rules[' + i + '].permission'] = rule.permission ? rule.permission : 'deny' + params['rules[' + i + '].description'] = rule.description ? rule.description : '' + } + return params + }, + handleSubmit (e) { + e.preventDefault() + if (this.loading) return + this.formRef.value.validate().then(() => { + const params = this.buildRequestParams() + this.loading = true + postAPI('registerUserKeys', params).then(response => { + this.$pollJob({ + jobId: response.registeruserkeysresponse.jobid, + successMessage: `${this.$t('message.success.register.user.keypair')} ${this.$t('label.for')} user ${this.resource.id}`, + successMethod: () => { + this.fetchData() + }, + errorMessage: this.$t('message.register.keypair.failed'), + errorMethod: () => { + this.fetchData() + }, + loadingMessage: `${this.$t('label.registering.keypair')} ${this.$t('label.for')} user ${this.resource.id} ${this.$t('label.is.in.progress')}`, + catchMessage: this.$t('error.fetching.async.job.result') + }) + }).catch(error => { + this.$notification.error({ + message: this.$t('message.request.failed'), + description: (error.response && error.response.headers && error.response.headers['x-description']) || error.message, + duration: 0 + }) + }).finally(() => { + this.loading = false + this.closeModal() + }) + }) + }, + closeModal () { + this.form.name = '' + this.form.description = '' + this.form.startDate = null + this.form.endDate = null Review Comment: Closing the modal resets form fields but does not reset `rules`. Reopening the modal will keep the previous rule set and submit stale permissions; clear `this.rules` (and any related state) when closing. ########## ui/src/views/iam/GenerateApiKeyPair.vue: ########## @@ -0,0 +1,229 @@ +// 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" v-ctrl-enter="handleSubmit"> + <a-modal + v-if="showAddKeyPair" + :visible="showAddKeyPair" + :closable="true" + :maskClosable="false" + :okText="$t('label.ok')" + :cancelText="$t('label.cancel')" + style="top: 20px;" + width="50vw" + @cancel="closeModal" + @ok="handleSubmit" + :ok-button-props="{props: { type: 'default' } }" + :cancel-button-props="{props: { type: 'primary' } }" + centered> + <template #title> + {{ $t('label.action.create.api.key') }} + </template> + <a-spin :spinning="loading"> + <a-form + :ref="formRef" + :model="form" + layout="vertical" + @finish="handleSubmit"> + <a-alert + style="margin-bottom: 10px; " + :message="$t('message.note.about.keypair.permissions.title')" + :description="$t('message.note.about.keypair.permissions.body')" + type="info" + show-icon + /> + <a-form-item name="name" ref="name"> + <template #label> + <tooltip-label :title="$t('label.name')" :tooltip="apiParams.name.description"/> + </template> + <a-input + v-focus="true" + :placeholder="$t('label.apikeypair.name')" + v-model:value="form.name" /> + </a-form-item> + <a-form-item name="description" ref="description"> + <template #label> + <tooltip-label :title="$t('label.description')" :tooltip="apiParams.description.description"/> + </template> + <a-input + v-model:value="form.description" + :placeholder="$t('label.apikeypair.description')" /> + </a-form-item> + <a-row> + <a-form-item ref="startDate" name="startDate"> + <template #label> + <tooltip-label :title="$t('label.start.date')" :tooltip="apiParams.startdate.description"/> + </template> + <a-date-picker + v-model:value="form.startDate" + :disabled-date="disabledStartDate" + show-time + /> + </a-form-item> + <a-form-item ref="endDate" name="endDate" style="margin: 0 8px"> + <template #label> + <tooltip-label :title="$t('label.end.date')" :tooltip="apiParams.enddate.description"/> + </template> + <a-date-picker + :disabled-date="disabledEndDate" + v-model:value="form.endDate" + show-time /> + </a-form-item> + </a-row> + <a-form-item> + <template #label> + <tooltip-label :title="$t('label.rules')" :tooltip="apiParams.rules.description"/> + </template> + <api-key-pair-permission-table + :resource="resource" + @update-rules="updateRules"/> + </a-form-item> + </a-form> + </a-spin> + </a-modal> + </div> +</template> + +<script> +import { ref, reactive, toRaw } from 'vue' +import { postAPI } from '@/api' +import TooltipLabel from '@/components/widgets/TooltipLabel' +import ApiKeyPairPermissionTable from '@/views/iam/ApiKeyPairPermissionTable.vue' +import { dayjs, parseDayJsObject } from '@/utils/date' + +export default { + name: 'GenerateApiKeyPair', + components: { + TooltipLabel, + ApiKeyPairPermissionTable + }, + props: { + showAddKeyPair: { + type: Boolean, + default: false + }, + resource: { + type: Object, + required: true + } + }, + data () { + return { + rules: [], + loading: false + } + }, + beforeCreate () { + this.apiParams = this.$getApiParams('registerUserKeys') + }, + created () { + this.initForm() + }, + methods: { + initForm () { + this.formRef = ref() + this.form = reactive({}) + }, + isValidValueForKey (obj, key) { + return key in obj && obj[key] != null + }, + buildRequestParams () { + const values = toRaw(this.form) + this.loading = true + const params = { + name: values.name, + id: this.resource.id, + description: values.description ? values.description : null, + startdate: values.startDate ? parseDayJsObject({ value: values.startDate }) : null, + endDate: values.endDate ? parseDayJsObject({ value: values.endDate }) : null Review Comment: `registerUserKeys` request params uses `endDate` instead of the API parameter name `enddate` (see `apiParams.enddate`). This will prevent the end date from being sent/recognized by the backend; rename the param key to `enddate` (keeping `form.endDate` as the UI model if desired). ########## ui/src/components/view/ApiKeyPairsTab.vue: ########## @@ -0,0 +1,455 @@ +// 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> + <a-spin :spinning="fetchLoading"> + <a-button + v-if="'registerUserKeys' in $store.getters.apis" + type="dashed" + style="width: 100%; margin-bottom: 15px" + @click="onShowAddKeyPair()"> + <template #icon><plus-outlined /></template> + {{ $t('label.register.api.key') }} + </a-button> + <a-button + v-if="this.selectedRowKeys.length > 0 && ('deleteUserKeys' in $store.getters.apis)" + type="primary" + danger + style="width: 100%; margin-bottom: 15px" + @click="bulkActionConfirmation()"> + <template #icon><delete-outlined /></template> + {{ $t('label.action.bulk.delete.api.keys') }} + </a-button> + <a-table + size="small" + style="overflow-y: auto" + :columns="columns" + :dataSource="keypairs" + :rowKey="item => item.id" + :key="item => item.id" + :rowSelection="rowSelection()" + :pagination="false" > + <template #name="{ record }"> + <div> + <router-link :to="{ path: '/keypair/' + record.id }" > + {{ record.name }} + </router-link> + </div> + </template> + <template #apikey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.apikey" /> + </strong> + <div> + {{ record.apikey.substring(0, 20) }}... + </div> + </template> + + <template #secretkey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.secretkey" /> + </strong> + <div> + {{ record.secretkey.substring(0, 20) }}... + </div> + </template> + + <template #startdate="{ record }"> + <div> {{ $toLocaleDate(record.startdate) }} </div> + </template> + + <template #enddate="{ record }"> + <div> {{ $toLocaleDate(record.enddate)}} </div> + </template> + + <template #created="{ record }"> + <div> {{ $toLocaleDate(record.created) }} </div> + </template> + + </a-table> + <a-divider/> + <a-pagination + class="row-element pagination" + size="small" + :current="page" + :pageSize="pageSize" + :total="totalKeypairs" + :showTotal="total => `${$t('label.total')} ${total} ${$t('label.items')}`" + :pageSizeOptions="['10', '20', '40', '80', '100']" + @change="changePage" + @showSizeChange="changePageSize" + showSizeChanger> + <template #buildOptionText="props"> + <span>{{ props.value }} / {{ $t('label.page') }}</span> + </template> + </a-pagination> + </a-spin> + <bulk-action-view + v-if="(showConfirmationAction || showGroupActionModal)" + :showConfirmationAction="showConfirmationAction" + :showGroupActionModal="showGroupActionModal" + :items="keypairs" + :selectedRowKeys="selectedRowKeys" + :selectedItems="selectedItems" + :columns="columns" + :selectedColumns="selectedColumns" + action="eraseKeypairs" + :loading="loading" + :message="bulkDeleteMessage" + @group-action="eraseKeypairs" + @handle-cancel="handleCancelBulk" + @close-modal="closeModalBulk" /> + <generate-api-key-pair + :showAddKeyPair="showAddKeyPair" + :resource="resource" + @fetch-data="fetchData" + @handle-cancel="handleCancelAddKeyPair" + @refresh-data="handleRefreshData" + @close-modal="closeModalAddKeyPair" /> + </div> +</template> +<script> +import { getAPI, postAPI } from '@/api' +import Status from '@/components/widgets/Status' +import TooltipButton from '@/components/widgets/TooltipButton' +import BulkActionView from '@/components/view/BulkActionView.vue' +import eventBus from '@/config/eventBus' +import OwnershipSelection from '@/views/compute/wizard/OwnershipSelection.vue' +import GenerateApiKeyPair from '@/views/iam/GenerateApiKeyPair.vue' +import store from '@/store' + +export default { + name: 'ApiKeyPairsTab', + components: { + OwnershipSelection, + Status, + TooltipButton, + BulkActionView, + GenerateApiKeyPair, + store + }, + props: { + resource: { + type: Object, + required: true + }, + loading: { + type: Boolean, + default: false + } + }, + data () { + return { + fetchLoading: false, + keypairs: [], + page: 1, + pageSize: 10, + totalKeypairs: 0, + selectedRowKeys: [], + selectedItems: [], + selectedColumns: [], + filterColumns: ['Action'], + showConfirmationAction: false, + showAddKeyPair: false, + showGroupActionModal: false, + bulkDeleteMessage: { + title: this.$t('label.action.bulk.delete.api.keys'), + confirmMessage: this.$t('label.confirm.delete.api.keys') + }, + columns: [ + { + title: this.$t('label.name'), + dataIndex: 'name', + slots: { customRender: 'name' } + }, + { + title: this.$t('label.apikey'), + dataIndex: 'apikey', + slots: { customRender: 'apikey' } + }, + { + title: this.$t('label.secretkey'), + dataIndex: 'secretkey', + slots: { customRender: 'secretkey' } + }, + { + title: this.$t('label.start.date'), + dataIndex: 'startdate', + slots: { customRender: 'startdate' } + }, + { + title: this.$t('label.end.date'), + dataIndex: 'enddate', + slots: { customRender: 'enddate' } + }, + { + title: this.$t('label.created'), + dataIndex: 'created', + slots: { customRender: 'created' } + } + ] + } + }, + created () { + this.fetchData() + }, + watch: { + resource: { + deep: true, + handler (newItem) { + if (!newItem || !newItem.id) { + return + } + this.fetchData() + } + } + }, + inject: ['parentFetchData'], + methods: { + fetchData () { + const params = { + listall: true, + page: this.page, + pagesize: this.pageSize, + userid: this.resource.id + } + this.fetchLoading = true + getAPI('listUserKeys', params).then(json => { + this.totalKeypairs = json.listuserkeysresponse.count || 0 + this.keypairs = json.listuserkeysresponse.userapikey || [] + }).finally(() => { + this.fetchLoading = false + }) + }, + setSelection (selection) { + this.selectedRowKeys = selection + this.$emit('selection-change', this.selectedRowKeys) + this.selectedItems = (this.keypairs.filter(function (item) { + return selection.indexOf(item.id) !== -1 + })) + }, + changePage (page, pageSize) { + this.page = page + this.pageSize = pageSize + this.fetchData() + }, + changePageSize (currentPage, pageSize) { + this.page = currentPage + this.pageSize = pageSize + this.fetchData() + }, + onShowAddKeyPair () { + this.showAddKeyPair = true + }, + eraseKeypairs () { + this.selectedColumns.splice(0, 0, { + dataIndex: 'status', + title: this.$t('label.operation.status'), + slots: { customRender: 'status' }, + filters: [ + { text: 'In Progress', value: 'InProgress' }, + { text: 'Success', value: 'success' }, + { text: 'Failed', value: 'failed' } + ] + }) + if (this.selectedRowKeys.length > 0) { + this.showGroupActionModal = true + } + this.deleteKeypairs(this.selectedItems) + }, + deleteKeypairs (keypairs) { + this.fetchLoading = true + keypairs.forEach(async keypair => { + try { + const jobId = await this.deleteKeyPair({ + keypairid: keypair.id + }) + await this.$pollJob({ + jobId, + action: { + isFetchData: false + }, + successMethod: () => { + eventBus.emit('update-resource-state', { selectedItems: this.selectedItems, resource: keypair.id, state: 'success' }) + }, + catchMethod: () => { + eventBus.emit('update-resource-state', { selectedItems: this.selectedItems, resource: keypair.id, state: 'failed' }) + } + }) + } catch (e) { + eventBus.emit('update-resource-state', { selectedItems: this.selectedItems, resource: keypair.id, state: 'failed' }) + } finally { + this.fetchLoading = false + } + }) Review Comment: `deleteKeypairs` uses `forEach(async ...)` which is not awaited, and it clears `fetchLoading` inside each task; the spinner can stop early while deletions are still running. Switch to `for...of` with `await` or aggregate with `Promise.allSettled` and only clear loading once. ########## ui/src/components/view/ApiKeyPairsTab.vue: ########## @@ -0,0 +1,455 @@ +// 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> + <a-spin :spinning="fetchLoading"> + <a-button + v-if="'registerUserKeys' in $store.getters.apis" + type="dashed" + style="width: 100%; margin-bottom: 15px" + @click="onShowAddKeyPair()"> + <template #icon><plus-outlined /></template> + {{ $t('label.register.api.key') }} + </a-button> + <a-button + v-if="this.selectedRowKeys.length > 0 && ('deleteUserKeys' in $store.getters.apis)" + type="primary" + danger + style="width: 100%; margin-bottom: 15px" + @click="bulkActionConfirmation()"> + <template #icon><delete-outlined /></template> + {{ $t('label.action.bulk.delete.api.keys') }} + </a-button> + <a-table + size="small" + style="overflow-y: auto" + :columns="columns" + :dataSource="keypairs" + :rowKey="item => item.id" + :key="item => item.id" + :rowSelection="rowSelection()" + :pagination="false" > + <template #name="{ record }"> + <div> + <router-link :to="{ path: '/keypair/' + record.id }" > + {{ record.name }} + </router-link> + </div> + </template> + <template #apikey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.apikey" /> + </strong> + <div> + {{ record.apikey.substring(0, 20) }}... + </div> + </template> + + <template #secretkey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.secretkey" /> + </strong> + <div> + {{ record.secretkey.substring(0, 20) }}... + </div> + </template> + + <template #startdate="{ record }"> + <div> {{ $toLocaleDate(record.startdate) }} </div> + </template> + + <template #enddate="{ record }"> + <div> {{ $toLocaleDate(record.enddate)}} </div> + </template> + + <template #created="{ record }"> + <div> {{ $toLocaleDate(record.created) }} </div> + </template> + + </a-table> + <a-divider/> + <a-pagination + class="row-element pagination" + size="small" + :current="page" + :pageSize="pageSize" + :total="totalKeypairs" + :showTotal="total => `${$t('label.total')} ${total} ${$t('label.items')}`" + :pageSizeOptions="['10', '20', '40', '80', '100']" + @change="changePage" + @showSizeChange="changePageSize" + showSizeChanger> + <template #buildOptionText="props"> + <span>{{ props.value }} / {{ $t('label.page') }}</span> + </template> + </a-pagination> + </a-spin> + <bulk-action-view + v-if="(showConfirmationAction || showGroupActionModal)" + :showConfirmationAction="showConfirmationAction" + :showGroupActionModal="showGroupActionModal" + :items="keypairs" + :selectedRowKeys="selectedRowKeys" + :selectedItems="selectedItems" + :columns="columns" + :selectedColumns="selectedColumns" + action="eraseKeypairs" + :loading="loading" + :message="bulkDeleteMessage" + @group-action="eraseKeypairs" + @handle-cancel="handleCancelBulk" + @close-modal="closeModalBulk" /> + <generate-api-key-pair + :showAddKeyPair="showAddKeyPair" + :resource="resource" + @fetch-data="fetchData" + @handle-cancel="handleCancelAddKeyPair" + @refresh-data="handleRefreshData" + @close-modal="closeModalAddKeyPair" /> + </div> +</template> +<script> +import { getAPI, postAPI } from '@/api' +import Status from '@/components/widgets/Status' +import TooltipButton from '@/components/widgets/TooltipButton' +import BulkActionView from '@/components/view/BulkActionView.vue' +import eventBus from '@/config/eventBus' +import OwnershipSelection from '@/views/compute/wizard/OwnershipSelection.vue' +import GenerateApiKeyPair from '@/views/iam/GenerateApiKeyPair.vue' +import store from '@/store' + +export default { + name: 'ApiKeyPairsTab', + components: { + OwnershipSelection, + Status, + TooltipButton, + BulkActionView, + GenerateApiKeyPair, + store + }, + props: { + resource: { + type: Object, + required: true + }, + loading: { + type: Boolean, + default: false + } + }, + data () { + return { + fetchLoading: false, + keypairs: [], + page: 1, + pageSize: 10, + totalKeypairs: 0, + selectedRowKeys: [], + selectedItems: [], + selectedColumns: [], + filterColumns: ['Action'], + showConfirmationAction: false, + showAddKeyPair: false, + showGroupActionModal: false, + bulkDeleteMessage: { + title: this.$t('label.action.bulk.delete.api.keys'), + confirmMessage: this.$t('label.confirm.delete.api.keys') + }, + columns: [ + { + title: this.$t('label.name'), + dataIndex: 'name', + slots: { customRender: 'name' } + }, + { + title: this.$t('label.apikey'), + dataIndex: 'apikey', + slots: { customRender: 'apikey' } + }, + { + title: this.$t('label.secretkey'), + dataIndex: 'secretkey', + slots: { customRender: 'secretkey' } + }, + { + title: this.$t('label.start.date'), + dataIndex: 'startdate', + slots: { customRender: 'startdate' } + }, + { + title: this.$t('label.end.date'), + dataIndex: 'enddate', + slots: { customRender: 'enddate' } + }, + { + title: this.$t('label.created'), + dataIndex: 'created', + slots: { customRender: 'created' } + } + ] + } + }, + created () { + this.fetchData() + }, + watch: { + resource: { + deep: true, + handler (newItem) { + if (!newItem || !newItem.id) { + return + } + this.fetchData() + } + } + }, + inject: ['parentFetchData'], + methods: { + fetchData () { + const params = { + listall: true, Review Comment: `listUserKeys`'s `listall` parameter is restricted to admin roles in the backend, but this tab always sends `listall: true`. This will likely fail for normal users; only send `listall` for admin/domainadmin/resourceadmin, or omit it and rely on `userid` to scope the results. ########## ui/src/components/view/ApiKeyPairsTab.vue: ########## @@ -0,0 +1,455 @@ +// 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> + <a-spin :spinning="fetchLoading"> + <a-button + v-if="'registerUserKeys' in $store.getters.apis" + type="dashed" + style="width: 100%; margin-bottom: 15px" + @click="onShowAddKeyPair()"> + <template #icon><plus-outlined /></template> + {{ $t('label.register.api.key') }} + </a-button> + <a-button + v-if="this.selectedRowKeys.length > 0 && ('deleteUserKeys' in $store.getters.apis)" + type="primary" + danger + style="width: 100%; margin-bottom: 15px" + @click="bulkActionConfirmation()"> + <template #icon><delete-outlined /></template> + {{ $t('label.action.bulk.delete.api.keys') }} + </a-button> + <a-table + size="small" + style="overflow-y: auto" + :columns="columns" + :dataSource="keypairs" + :rowKey="item => item.id" + :key="item => item.id" + :rowSelection="rowSelection()" + :pagination="false" > + <template #name="{ record }"> + <div> + <router-link :to="{ path: '/keypair/' + record.id }" > + {{ record.name }} + </router-link> + </div> + </template> + <template #apikey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.apikey" /> + </strong> + <div> + {{ record.apikey.substring(0, 20) }}... + </div> + </template> + + <template #secretkey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.secretkey" /> + </strong> + <div> + {{ record.secretkey.substring(0, 20) }}... + </div> + </template> + + <template #startdate="{ record }"> + <div> {{ $toLocaleDate(record.startdate) }} </div> + </template> + + <template #enddate="{ record }"> + <div> {{ $toLocaleDate(record.enddate)}} </div> + </template> + + <template #created="{ record }"> + <div> {{ $toLocaleDate(record.created) }} </div> + </template> + + </a-table> + <a-divider/> + <a-pagination + class="row-element pagination" + size="small" + :current="page" + :pageSize="pageSize" + :total="totalKeypairs" + :showTotal="total => `${$t('label.total')} ${total} ${$t('label.items')}`" + :pageSizeOptions="['10', '20', '40', '80', '100']" + @change="changePage" + @showSizeChange="changePageSize" + showSizeChanger> + <template #buildOptionText="props"> + <span>{{ props.value }} / {{ $t('label.page') }}</span> + </template> + </a-pagination> + </a-spin> + <bulk-action-view + v-if="(showConfirmationAction || showGroupActionModal)" + :showConfirmationAction="showConfirmationAction" + :showGroupActionModal="showGroupActionModal" + :items="keypairs" + :selectedRowKeys="selectedRowKeys" + :selectedItems="selectedItems" + :columns="columns" + :selectedColumns="selectedColumns" + action="eraseKeypairs" + :loading="loading" + :message="bulkDeleteMessage" + @group-action="eraseKeypairs" + @handle-cancel="handleCancelBulk" + @close-modal="closeModalBulk" /> + <generate-api-key-pair + :showAddKeyPair="showAddKeyPair" + :resource="resource" + @fetch-data="fetchData" + @handle-cancel="handleCancelAddKeyPair" + @refresh-data="handleRefreshData" + @close-modal="closeModalAddKeyPair" /> + </div> +</template> +<script> +import { getAPI, postAPI } from '@/api' +import Status from '@/components/widgets/Status' +import TooltipButton from '@/components/widgets/TooltipButton' +import BulkActionView from '@/components/view/BulkActionView.vue' +import eventBus from '@/config/eventBus' +import OwnershipSelection from '@/views/compute/wizard/OwnershipSelection.vue' +import GenerateApiKeyPair from '@/views/iam/GenerateApiKeyPair.vue' +import store from '@/store' + +export default { + name: 'ApiKeyPairsTab', + components: { + OwnershipSelection, + Status, + TooltipButton, + BulkActionView, + GenerateApiKeyPair, + store Review Comment: `store` is being registered under `components`, but Vue components must be component definitions (not the Vuex store instance). This will cause warnings and is unnecessary; remove `store` from the `components` object and keep using the imported `store` in methods/computed. ########## ui/src/components/view/ApiKeyPairsTab.vue: ########## @@ -0,0 +1,455 @@ +// 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> + <a-spin :spinning="fetchLoading"> + <a-button + v-if="'registerUserKeys' in $store.getters.apis" + type="dashed" + style="width: 100%; margin-bottom: 15px" + @click="onShowAddKeyPair()"> + <template #icon><plus-outlined /></template> + {{ $t('label.register.api.key') }} + </a-button> + <a-button + v-if="this.selectedRowKeys.length > 0 && ('deleteUserKeys' in $store.getters.apis)" + type="primary" + danger + style="width: 100%; margin-bottom: 15px" + @click="bulkActionConfirmation()"> + <template #icon><delete-outlined /></template> + {{ $t('label.action.bulk.delete.api.keys') }} + </a-button> + <a-table + size="small" + style="overflow-y: auto" + :columns="columns" + :dataSource="keypairs" + :rowKey="item => item.id" + :key="item => item.id" Review Comment: The table sets `:key="item => item.id"`, but `key` is a VNode key and should be a primitive, not a function. This can lead to unstable rendering; remove this binding (you already have `:rowKey`) or set a stable primitive key. ########## api/src/main/java/org/apache/cloudstack/api/command/admin/user/RegisterUserKeysCmd.java: ########## @@ -138,6 +138,9 @@ public List<Map<String, Object>> getRules() { String description = detail.get(ApiConstants.DESCRIPTION); if (StringUtils.isNotEmpty(description)) { + if (permission.length() > 255) { Review Comment: Rule description length validation checks `permission.length()` instead of `description.length()`, so overlong descriptions will not be rejected and short permissions could incorrectly trigger the error. Validate the actual description string length against the DB column limit (255). ########## ui/src/components/view/ApiKeyPairsTab.vue: ########## @@ -0,0 +1,455 @@ +// 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> + <a-spin :spinning="fetchLoading"> + <a-button + v-if="'registerUserKeys' in $store.getters.apis" + type="dashed" + style="width: 100%; margin-bottom: 15px" + @click="onShowAddKeyPair()"> + <template #icon><plus-outlined /></template> + {{ $t('label.register.api.key') }} + </a-button> + <a-button + v-if="this.selectedRowKeys.length > 0 && ('deleteUserKeys' in $store.getters.apis)" + type="primary" + danger + style="width: 100%; margin-bottom: 15px" + @click="bulkActionConfirmation()"> + <template #icon><delete-outlined /></template> + {{ $t('label.action.bulk.delete.api.keys') }} + </a-button> + <a-table + size="small" + style="overflow-y: auto" + :columns="columns" + :dataSource="keypairs" + :rowKey="item => item.id" + :key="item => item.id" + :rowSelection="rowSelection()" + :pagination="false" > + <template #name="{ record }"> + <div> + <router-link :to="{ path: '/keypair/' + record.id }" > + {{ record.name }} + </router-link> + </div> + </template> + <template #apikey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.apikey" /> + </strong> + <div> + {{ record.apikey.substring(0, 20) }}... + </div> + </template> + + <template #secretkey="{ record }"> + <strong> + <tooltip-button + tooltipPlacement="right" + :tooltip="$t('label.copy')" + icon="CopyOutlined" + type="dashed" + size="small" + @onClick="$message.success($t('label.copied.clipboard'))" + :copyResource="record.secretkey" /> + </strong> + <div> + {{ record.secretkey.substring(0, 20) }}... + </div> + </template> + + <template #startdate="{ record }"> + <div> {{ $toLocaleDate(record.startdate) }} </div> + </template> + + <template #enddate="{ record }"> + <div> {{ $toLocaleDate(record.enddate)}} </div> + </template> + + <template #created="{ record }"> + <div> {{ $toLocaleDate(record.created) }} </div> + </template> + + </a-table> + <a-divider/> + <a-pagination + class="row-element pagination" + size="small" + :current="page" + :pageSize="pageSize" + :total="totalKeypairs" + :showTotal="total => `${$t('label.total')} ${total} ${$t('label.items')}`" + :pageSizeOptions="['10', '20', '40', '80', '100']" + @change="changePage" + @showSizeChange="changePageSize" + showSizeChanger> + <template #buildOptionText="props"> + <span>{{ props.value }} / {{ $t('label.page') }}</span> + </template> + </a-pagination> + </a-spin> + <bulk-action-view + v-if="(showConfirmationAction || showGroupActionModal)" + :showConfirmationAction="showConfirmationAction" + :showGroupActionModal="showGroupActionModal" + :items="keypairs" + :selectedRowKeys="selectedRowKeys" + :selectedItems="selectedItems" + :columns="columns" + :selectedColumns="selectedColumns" + action="eraseKeypairs" + :loading="loading" + :message="bulkDeleteMessage" + @group-action="eraseKeypairs" + @handle-cancel="handleCancelBulk" + @close-modal="closeModalBulk" /> + <generate-api-key-pair + :showAddKeyPair="showAddKeyPair" + :resource="resource" + @fetch-data="fetchData" + @handle-cancel="handleCancelAddKeyPair" + @refresh-data="handleRefreshData" + @close-modal="closeModalAddKeyPair" /> + </div> +</template> +<script> +import { getAPI, postAPI } from '@/api' +import Status from '@/components/widgets/Status' +import TooltipButton from '@/components/widgets/TooltipButton' +import BulkActionView from '@/components/view/BulkActionView.vue' +import eventBus from '@/config/eventBus' +import OwnershipSelection from '@/views/compute/wizard/OwnershipSelection.vue' +import GenerateApiKeyPair from '@/views/iam/GenerateApiKeyPair.vue' +import store from '@/store' + +export default { + name: 'ApiKeyPairsTab', + components: { + OwnershipSelection, + Status, + TooltipButton, + BulkActionView, + GenerateApiKeyPair, + store + }, + props: { + resource: { + type: Object, + required: true + }, + loading: { + type: Boolean, + default: false + } + }, + data () { + return { + fetchLoading: false, + keypairs: [], + page: 1, + pageSize: 10, + totalKeypairs: 0, + selectedRowKeys: [], + selectedItems: [], + selectedColumns: [], + filterColumns: ['Action'], + showConfirmationAction: false, + showAddKeyPair: false, + showGroupActionModal: false, + bulkDeleteMessage: { + title: this.$t('label.action.bulk.delete.api.keys'), + confirmMessage: this.$t('label.confirm.delete.api.keys') + }, + columns: [ + { + title: this.$t('label.name'), + dataIndex: 'name', + slots: { customRender: 'name' } + }, + { + title: this.$t('label.apikey'), + dataIndex: 'apikey', + slots: { customRender: 'apikey' } + }, + { + title: this.$t('label.secretkey'), + dataIndex: 'secretkey', + slots: { customRender: 'secretkey' } + }, + { + title: this.$t('label.start.date'), + dataIndex: 'startdate', + slots: { customRender: 'startdate' } + }, + { + title: this.$t('label.end.date'), + dataIndex: 'enddate', + slots: { customRender: 'enddate' } + }, + { + title: this.$t('label.created'), + dataIndex: 'created', + slots: { customRender: 'created' } + } + ] + } + }, + created () { + this.fetchData() + }, + watch: { + resource: { + deep: true, + handler (newItem) { + if (!newItem || !newItem.id) { + return + } + this.fetchData() + } + } + }, + inject: ['parentFetchData'], + methods: { + fetchData () { + const params = { + listall: true, + page: this.page, + pagesize: this.pageSize, + userid: this.resource.id + } + this.fetchLoading = true + getAPI('listUserKeys', params).then(json => { + this.totalKeypairs = json.listuserkeysresponse.count || 0 + this.keypairs = json.listuserkeysresponse.userapikey || [] + }).finally(() => { + this.fetchLoading = false + }) + }, + setSelection (selection) { + this.selectedRowKeys = selection + this.$emit('selection-change', this.selectedRowKeys) + this.selectedItems = (this.keypairs.filter(function (item) { + return selection.indexOf(item.id) !== -1 + })) + }, + changePage (page, pageSize) { + this.page = page + this.pageSize = pageSize + this.fetchData() + }, + changePageSize (currentPage, pageSize) { + this.page = currentPage + this.pageSize = pageSize + this.fetchData() + }, + onShowAddKeyPair () { + this.showAddKeyPair = true + }, + eraseKeypairs () { + this.selectedColumns.splice(0, 0, { + dataIndex: 'status', + title: this.$t('label.operation.status'), + slots: { customRender: 'status' }, + filters: [ + { text: 'In Progress', value: 'InProgress' }, + { text: 'Success', value: 'success' }, + { text: 'Failed', value: 'failed' } + ] + }) + if (this.selectedRowKeys.length > 0) { + this.showGroupActionModal = true + } + this.deleteKeypairs(this.selectedItems) + }, + deleteKeypairs (keypairs) { + this.fetchLoading = true + keypairs.forEach(async keypair => { + try { + const jobId = await this.deleteKeyPair({ + keypairid: keypair.id + }) + await this.$pollJob({ + jobId, + action: { + isFetchData: false + }, + successMethod: () => { + eventBus.emit('update-resource-state', { selectedItems: this.selectedItems, resource: keypair.id, state: 'success' }) + }, + catchMethod: () => { + eventBus.emit('update-resource-state', { selectedItems: this.selectedItems, resource: keypair.id, state: 'failed' }) + } + }) + } catch (e) { + eventBus.emit('update-resource-state', { selectedItems: this.selectedItems, resource: keypair.id, state: 'failed' }) + } finally { + this.fetchLoading = false + } + }) Review Comment: `fetchLoading` is set to false in each iteration's `finally`, so with multiple selected keypairs the loading state will be incorrect (it will flip to false after the first finishes). Only clear `fetchLoading` once all deletions complete. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
