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]

Reply via email to