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

winterhazel pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new 8ef036e7175 UI for API Key Pair Management (#13225)
8ef036e7175 is described below

commit 8ef036e717524396ae790a502f0e01a0b63a0cb1
Author: Bernardo De Marco Gonçalves <[email protected]>
AuthorDate: Mon Jun 22 19:13:36 2026 -0300

    UI for API Key Pair Management (#13225)
    
    Co-authored-by: Fabricio Duarte <[email protected]>
---
 .../command/admin/user/RegisterUserKeysCmd.java    |   5 +-
 .../org/apache/cloudstack/acl/ApiKeyPairVO.java    |   2 +-
 .../resources/META-INF/db/schema-42210to42300.sql  |   8 +-
 ui/public/locales/en.json                          |  23 +-
 ui/public/locales/pt_BR.json                       |  21 +-
 ui/src/components/view/ApiKeyPairsTab.vue          | 451 ++++++++++++++++++
 ui/src/components/view/DetailsTab.vue              |   4 +-
 ui/src/components/view/InfoCard.vue                | 104 ++---
 ui/src/components/view/ListView.vue                |   2 +-
 ui/src/config/router.js                            |   2 +
 ui/src/config/section/keypair.js                   |  69 +++
 ui/src/config/section/user.js                      |   6 +
 ui/src/views/AutogenView.vue                       |   7 +-
 ui/src/views/iam/ApiKeyPairPermissionTable.vue     | 518 +++++++++++++++++++++
 ui/src/views/iam/GenerateApiKeyPair.vue            | 226 +++++++++
 15 files changed, 1384 insertions(+), 64 deletions(-)

diff --git 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/RegisterUserKeysCmd.java
 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/RegisterUserKeysCmd.java
index 11d7c1d2ffa..28c79517f4b 100644
--- 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/RegisterUserKeysCmd.java
+++ 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/RegisterUserKeysCmd.java
@@ -50,7 +50,7 @@ public class RegisterUserKeysCmd extends BaseAsyncCmd {
     @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, 
description = "API key pair name.")
     private String name;
 
-    @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, 
description = "API key pair description.")
+    @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, 
description = "API key pair description.", length = 1024)
     private String description;
 
     @Parameter(name = ApiConstants.START_DATE, type = CommandType.DATE, 
description = "Start date of the API key pair. " +
@@ -138,6 +138,9 @@ public class RegisterUserKeysCmd extends BaseAsyncCmd {
 
             String description = detail.get(ApiConstants.DESCRIPTION);
             if (StringUtils.isNotEmpty(description)) {
+                if (description.length() > 255) {
+                    throw new ServerApiException(ApiErrorCode.PARAM_ERROR, 
"Rule description cannot be longer than 255 characters.");
+                }
                 ruleDetails.put(ApiConstants.DESCRIPTION, description);
             }
 
diff --git 
a/engine/schema/src/main/java/org/apache/cloudstack/acl/ApiKeyPairVO.java 
b/engine/schema/src/main/java/org/apache/cloudstack/acl/ApiKeyPairVO.java
index eb38b08f615..7a6bd7a3b14 100644
--- a/engine/schema/src/main/java/org/apache/cloudstack/acl/ApiKeyPairVO.java
+++ b/engine/schema/src/main/java/org/apache/cloudstack/acl/ApiKeyPairVO.java
@@ -70,7 +70,7 @@ public class ApiKeyPairVO implements ApiKeyPair {
     @Temporal(value = TemporalType.TIMESTAMP)
     private Date created = Date.from(Instant.now());
 
-    @Column(name = "description")
+    @Column(name = "description", length = 1024)
     private String description = "";
 
     @Column(name = "api_key", nullable = false)
diff --git 
a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql 
b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
index 75ee26ff219..4f4d37fa8c2 100644
--- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
+++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
@@ -73,7 +73,7 @@ CREATE TABLE IF NOT EXISTS `cloud`.`api_keypair` (
     `user_id` bigint(20) unsigned NOT NULL,
     `start_date` datetime,
     `end_date` datetime,
-    `description` varchar(100),
+    `description` varchar(1024),
     `api_key` varchar(255) NOT NULL,
     `secret_key` varchar(255) NOT NULL,
     `created` datetime NOT NULL,
@@ -107,11 +107,15 @@ WHERE user.api_key IS NOT NULL AND user.secret_key IS NOT 
NULL;
 -- Drop API keys from user table
 ALTER TABLE `cloud`.`user` DROP COLUMN api_key, DROP COLUMN secret_key;
 
--- Grant access to the "deleteUserKeys" API to the "User", "Domain Admin" and 
"Resource Admin" roles, similarly to the "registerUserKeys" API
+-- Grant access to the "deleteUserKeys" and "listUserKeyRules" APIs to the 
"User", "Domain Admin" and "Resource Admin" roles, similarly to the 
"registerUserKeys" API
 CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('User', 'deleteUserKeys', 
'ALLOW');
 CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('Domain Admin', 
'deleteUserKeys', 'ALLOW');
 CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('Resource Admin', 
'deleteUserKeys', 'ALLOW');
 
+CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('User', 'listUserKeyRules', 
'ALLOW');
+CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('Domain Admin', 
'listUserKeyRules', 'ALLOW');
+CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('Resource Admin', 
'listUserKeyRules', 'ALLOW');
+
 -- Add conserve mode for VPC offerings
 CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 
'tinyint(1) unsigned NULL DEFAULT 0 COMMENT ''True if the VPC offering is IP 
conserve mode enabled, allowing public IP services to be used across multiple 
VPC tiers'' ');
 
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 4ba3dc26187..2eeffc405e7 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -62,6 +62,7 @@
 "label.action.attach.disk": "Attach Disk",
 "label.action.attach.iso": "Attach ISO",
 "label.action.attach.to.instance": "Attach to Instance",
+"label.action.bulk.delete.api.keys": "Bulk delete API key pairs",
 "label.action.bulk.delete.egress.firewall.rules": "Bulk delete egress firewall 
rules",
 "label.action.bulk.delete.firewall.rules": "Bulk delete firewall rules",
 "label.action.bulk.delete.ip.v6.firewall.rules": "Bulk remove IPv6 firewall 
rules",
@@ -82,6 +83,7 @@
 "label.action.copy.iso": "Copy ISO",
 "label.action.copy.snapshot": "Copy Snapshot",
 "label.action.copy.template": "Copy Template",
+"label.action.create.api.key": "Create API key pair for user",
 "label.action.create.backup.schedule": "Create Backup Schedule",
 "label.action.create.recurring.snapshot": "Create Recurring Snapshot",
 "label.action.create.snapshot.from.vmsnapshot": "Create Snapshot from Instance 
Snapshot",
@@ -116,6 +118,7 @@
 "label.action.delete.snapshot": "Delete Snapshot",
 "label.action.delete.template": "Delete Template",
 "label.action.delete.tungsten.router.table": "Remove Tungsten Fabric route 
table from Network",
+"label.action.delete.keypair": "Delete API key pair",
 "label.action.delete.user": "Delete User",
 "label.action.delete.vgpu.profile": "Delete vGPU profile",
 "label.action.delete.volume": "Delete Volume",
@@ -409,6 +412,11 @@
 "label.api.docs.count": "APIs available for your account",
 "label.api.version": "API version",
 "label.apikey": "API key",
+"label.apikeypairs": "API Key Pairs",
+"label.apikeypair.description": "Description of the API key pair",
+"label.apikeypair.name": "Name of the API key pair",
+"label.apikeypair.startdate": "API key pair valid from",
+"label.apikeypair.enddate": "API key pair valid until",
 "label.app.cookie": "AppCookie",
 "label.app.name": "CloudStack",
 "label.application.policy.set": "Application Policy Set",
@@ -624,6 +632,7 @@
 "label.configure.ldap": "Configure LDAP",
 "label.configure.ovs": "Configure Ovs",
 "label.configure.sticky.policy": "Configure sticky policy",
+"label.confirm.delete.api.keys": "Please confirm you wish to delete the 
selected API key pairs",
 "label.confirm.delete.egress.firewall.rules": "Please confirm you wish to 
delete the selected egress firewall rules.",
 "label.confirm.delete.firewall.rules": "Please confirm you wish to delete the 
selected firewall rules.",
 "label.confirm.delete.ip.v6.firewall.rules": "Please confirm you wish to 
delete the selected IPv6 firewall rules",
@@ -786,6 +795,7 @@
 "label.delete.alerts": "Delete alerts",
 "label.delete.asnrange": "Delete AS Range",
 "label.delete.autoscale.vmgroup": "Delete AutoScaling Group",
+"label.delete.all.rules": "Delete all API key pair rules",
 "label.delete.backup": "Delete backup",
 "label.delete.backup.schedule": "Delete backup schedule",
 "label.delete.bgp.peer": "Delete BGP peer",
@@ -843,6 +853,7 @@
 "label.deleting": "Deleting",
 "label.deleting.failed": "Deleting failed",
 "label.deleting.iso": "Deleting ISO",
+"label.deleting.keypair": "Deleting API key pair",
 "label.deleting.snapshot": "Deleting Snapshot",
 "label.deleting.template": "Deleting Template",
 "label.deleteprotection": "Delete protection",
@@ -1643,6 +1654,7 @@
 "message.memory.usage.info.hypervisor.additionals": "The data shown may not 
reflect the actual memory usage if the Instance does not have the additional 
hypervisor tools installed",
 "message.memory.usage.info.negative.value": "If the Instance's memory usage 
cannot be obtained from the hypervisor, the lines for free memory in the raw 
data graph and memory usage in the percentage graph will be disabled",
 "message.migrate.volume.tooltip": "Volume can be migrated to any suitable 
storage pool. Admin has to choose the appropriate disk offering to replace, 
that supports the new storage pool",
+"message.register.keypair.failed": "Failed to register API key pair",
 "label.migrate.with.storage": "Migrate with storage",
 "label.migrating": "Migrating",
 "label.migrating.data": "Migrating data",
@@ -2083,12 +2095,14 @@
 "label.redundantvpcrouter": "Redundant VPC",
 "label.refresh": "Refresh",
 "label.region": "Region",
+"label.register.api.key": "Register API key pair",
 "label.register.extension": "Register Extension",
 "label.register.oauth": "Register OAuth",
 "label.register.template": "Register Template",
 "label.register.user.data": "Register User Data",
 "label.register.cni.config": "Register CNI Configuration",
 "label.register.user.data.details": "Enter the User Data in plain text or in 
Base64 encoding. Up to 32KB of Base64 encoded User Data can be sent by default. 
The setting vm.userdata.max.length can be used to increase the limit to upto 
1MB.",
+"label.registering.keypair": "Registering API key pair for user \"{user}\"",
 "label.reinstall.vm": "Reinstall Instance",
 "label.reject": "Reject",
 "label.related": "Related",
@@ -2403,6 +2417,7 @@
 "label.unregister.extension": "Unregister Extension",
 "label.usediops": "IOPS used",
 "label.userdata": "User Data",
+"label.user.api.key.rules": "API key pair rules",
 "label.user.data.id": "User Data ID",
 "label.user.data.name": "User Data name",
 "label.user.data.details": "User Data details",
@@ -2936,7 +2951,7 @@
 "label.versioning": "Versioning",
 "label.objectlocking": "Object Lock",
 "label.bucket.policy": "Bucket Policy",
-"label.usersecretkey": "Secret Key",
+"label.usersecretkey": "API Secret Key",
 "label.create.bucket": "Create Bucket",
 "label.cniconfiguration": "CNI Configuration",
 "label.cniconfigname": "Associated CNI Configuration",
@@ -3350,6 +3365,8 @@
 "message.delete.failed": "Delete fail",
 "message.delete.gateway": "Please confirm you want to delete the gateway.",
 "message.delete.ip.v6.prefix.processing": "Deleting IPv6 prefix...",
+"message.delete.keypair": "Please confirm that you would like to delete this 
API key pair.",
+"message.delete.keypair.failed": "Failed to delete API key pair",
 "message.delete.port.forward.processing": "Deleting port forwarding rule...",
 "message.delete.project": "Are you sure you want to delete this project?",
 "message.delete.rule.processing": "Deleting rule...",
@@ -3755,6 +3772,8 @@
 "message.new.version.available": "A new version of CloudStack is available. 
Click here to check the details",
 "message.no.data.to.show.for.period": "No data to show for the selected 
period.",
 "message.no.description": "No description entered.",
+"message.note.about.keypair.permissions.title": "Note about API key pair rule 
permissions",
+"message.note.about.keypair.permissions.body": "During the creation of API key 
pairs, it is possible to define a corresponding set of rule permissions. If a 
rule set is defined, the API key pair will only have access to APIs for which 
access has been explicitly granted (i.e., APIs whose corresponding rules are 
marked as allowed). On the other hand, if no rule set is specified, the API key 
pair permissions will follow and adapt to the permission set of the user's 
account role.",
 "message.offering.internet.protocol.warning": "WARNING: IPv6 supported 
Networks use static routing and will require upstream routes to be configured 
manually.",
 "message.offering.ipv6.warning": "Please refer documentation for creating IPv6 
enabled Network/VPC offering <a 
href='http://docs.cloudstack.apache.org/en/latest/plugins/ipv6.html#isolated-network-and-vpc-tier'>IPv6
 support in CloudStack - Isolated Networks and VPC Network Tiers</a>",
 "message.oobm.configured": "Successfully configured out-of-band management for 
host",
@@ -3963,6 +3982,7 @@
 "message.success.delete.gpu.devices": "Successfully deleted GPU device(s)",
 "message.success.delete.icon": "Successfully deleted icon of",
 "message.success.delete.interface.static.route": "Successfully removed 
interface Static Route",
+"message.success.delete.keypair": "Success deleting API key pair",
 "message.success.delete.ipv4.subnet": "Successfully removed IPv4 subnet",
 "message.success.delete.network.static.route": "Successfully removed Network 
Static Route",
 "message.success.delete.node": "Successfully deleted node",
@@ -3994,6 +4014,7 @@
 "message.success.register.keypair": "Successfully registered SSH key pair",
 "message.success.register.template": "Successfully registered Template",
 "message.success.register.user.data": "Successfully registered User Data",
+"message.success.register.user.keypair": "Successfully registered API key pair 
for user \"{user}\"",
 "message.success.release.ip": "Successfully released IP",
 "message.success.release.dedicated.bgp.peer": "Successfully released dedicated 
BGP peer",
 "message.success.release.dedicated.ipv4.subnet": "Successfully released 
dedicated IPv4 subnet",
diff --git a/ui/public/locales/pt_BR.json b/ui/public/locales/pt_BR.json
index 8f17ded6c79..c0d66280d7c 100644
--- a/ui/public/locales/pt_BR.json
+++ b/ui/public/locales/pt_BR.json
@@ -51,6 +51,7 @@
 "label.action": "A\u00e7\u00e3o",
 "label.action.attach.disk": "Anexar disco",
 "label.action.attach.iso": "Anexar ISO",
+"label.action.bulk.delete.api.keys": "Apagar em massa as chaves de acesso 
\u00e0 API.",
 "label.action.bulk.delete.egress.firewall.rules": "Apagar em massa as regras 
de sa\u00edda do firewall.",
 "label.action.bulk.delete.firewall.rules": "Apagar em massa as regras do 
firewall.",
 "label.action.bulk.delete.ip.v6.firewall.rules": "Apagar em massa as regras de 
firewall IPv6.",
@@ -70,6 +71,7 @@
 "label.action.copy.iso": "Copiar ISO",
 "label.action.copy.snapshot": "Copiar Snapshot",
 "label.action.copy.template": "Copiar template",
+"label.action.create.api.key": "Criar um par de chaves de API para 
usu\u00e1rio",
 "label.action.create.snapshot.from.vmsnapshot": "Criar snapshot a partir de 
uma snapshot de VM",
 "label.action.create.template.from.volume": "Criar template a partir do disco",
 "label.action.create.volume": "Criar disco",
@@ -86,6 +88,7 @@
 "label.action.delete.interface.static.route": "Remover rota est\u00e1tica da 
interface Tungsten Fabric",
 "label.action.delete.ip.range": "Remover intervalo de IPs",
 "label.action.delete.iso": "Remover ISO",
+"label.action.delete.keypair": "Remover par de chaves",
 "label.action.delete.load.balancer": "Remover regra do balanceador de carga",
 "label.action.delete.network": "Remover rede",
 "label.action.delete.network.permission": "Excluir permiss\u00e3o de Rede",
@@ -379,6 +382,11 @@
 "label.api.docs.count": "APIs dispon\u00edveis para sua conta",
 "label.api.version": "Vers\u00e3o da API",
 "label.apikey": "Chave da API",
+"label.apikeypairs": "Par de chaves de API",
+"label.apikeypair.description": "Descri\u00e7\u00e3o do par de chaves de API",
+"label.apikeypair.name": "Nome do par de chaves de API",
+"label.apikeypair.startdate": "Data de in\u00edcio da validade do par de 
chaves de API",
+"label.apikeypair.enddate": "Data de expira\u00e7\u00e3o do par de chaves de 
API",
 "label.app.cookie": "AppCookie",
 "label.app.name": "CloudStack",
 "label.application.policy.set": "Conjunto de Pol\u00edticas de 
Aplica\u00e7\u00e3o",
@@ -564,6 +572,7 @@
 "label.configure.ldap": "Configurar LDAP",
 "label.configure.ovs": "Configure Ovs",
 "label.configure.sticky.policy": "Configurar sticky policy",
+"label.confirm.delete.api.keys": "Por favor, confirme se deseja apagar as 
chaves de acesso \u00e0 API selecionadas",
 "label.confirm.delete.egress.firewall.rules": "Por favor, confirme se deseja 
apagar as regras de firewall de sa\u00edda selecionadas",
 "label.confirm.delete.firewall.rules": "Por favor, confirme se deseja apagar 
as regras de firewall selecionadas",
 "label.confirm.delete.ip.v6.firewall.rules": "Por favor, confirme que deseja 
excluir as regras de firewall IPv6 selecionadas",
@@ -709,6 +718,7 @@
 "label.delete.acl": "Apagar lista ACL",
 "label.delete.affinity.group": "Apagar grupo de afinidade",
 "label.delete.alerts": "Remover alertas",
+"label.delete.all.rules": "Apagar todas as regras",
 "label.delete.asnrange": "Excluir Faixa AS",
 "label.delete.autoscale.vmgroup": "Excluir grupo de auto escalonamento 
horizontal",
 "label.delete.backup": "Apagar backup",
@@ -763,6 +773,7 @@
 "label.deleteconfirm": "Por favor, confirme que voc\u00ea deseja apagar isto",
 "label.deleting": "Removendo",
 "label.deleting.failed": "Falha ao remover",
+"label.deleting.keypair": "Deletando uma chave de API",
 "label.deleting.iso": "Removendo ISO",
 "label.deleting.template": "Remover template",
 "label.deleting.snapshot": "Excluindo Snapshot",
@@ -1890,6 +1901,7 @@
 "label.register.oauth": "Registrar OAuth",
 "label.register.user.data": "Registrar dados de usu\u00e1rio",
 "label.register.template": "Registrar template",
+"label.registering.keypair": "Registrando par de chaves de API para o 
usu\u00e1rio \"{user}\"",
 "label.reinstall.vm": "Reinstalar VM",
 "label.reject": "Rejeitar",
 "label.related": "Relacionado",
@@ -2050,6 +2062,7 @@
 "label.secondarystoragelimit": "Limites do armazenamento secund\u00e1rio 
(GiB)",
 "label.secretkey": "Chave secreta",
 "label.secret.key": "Chave secreta",
+"label.apikeyaccess": "Acesso a pares de chaves de API",
 "label.secured": "Protegido",
 "label.security.groups": "Grupos de seguran\u00e7a",
 "label.securitygroup": "Grupo de seguran\u00e7a",
@@ -2466,10 +2479,12 @@
 "label.usenewdiskoffering": "Substituir a oferta de disco?",
 "label.user": "Usu\u00e1rio",
 "label.user.conflict": "Conflito",
+"label.usersecretkey": "Chave Secreta de API",
 "label.user.data": "Dados de usu\u00e1rio",
 "label.username": "Nome de usu\u00e1rio",
 "label.users": "Usu\u00e1rios",
 "label.usersource": "Tipo de usu\u00e1rio",
+"label.user.api.key.rules": "Regras do par de chaves de API",
 "label.using.cli": "Usando CLI",
 "label.utilization": "Utiliza\u00e7\u00e3o",
 "label.uuid": "ID",
@@ -3365,6 +3380,8 @@
 "message.nfs.mount.options.description": "Lista separada por v\u00edrgulas de 
op\u00e7\u00f5es de montagem NFS para hosts KVM. Op\u00e7\u00f5es suportadas : 
vers=[3,4.0,4.1,4.2], nconnect=[1...16]",
 "message.no.data.to.show.for.period": "Nenhum dado para mostrar no 
per\u00edodo selecionado.",
 "message.no.description": "Nenhuma descri\u00e7\u00e3o inserida.",
+"message.note.about.keypair.permissions.title": "Observa\u00e7\u00e3o sobre as 
permiss\u00f5es de regras de pares de chaves de API",
+"message.note.about.keypair.permissions.body": "Durante a cria\u00e7\u00e3o de 
pares de chaves de API, é possível definir um conjunto correspondente de 
permiss\u00f5es de regras. Se um conjunto de regras for definido, o par de 
chaves de API terá acesso apenas às APIs para as quais o acesso foi 
explicitamente concedido (ou seja, APIs cujas regras correspondentes estejam 
marcadas como permitidas). Por outro lado, se nenhum conjunto de regras for 
especificado, as permiss\u00f5es do par de c [...]
 "message.offering.internet.protocol.warning": "AVISO: Redes suportadas por 
IPv6 usam roteamento est\u00e1tico e exigir\u00e3o que rotas upstream sejam 
configuradas manualmente.",
 "message.offering.ipv6.warning": "Por favor, consulte a documenta\u00e7\u00e3o 
para criar oferta de Rede/VPC habilitada para IPv6 <a 
href='http://docs.cloudstack.apache.org/en/latest/plugins/ipv6.html#isolated-network-and-vpc-tier'>Suporte
 IPv6 no CloudStack - Redes Isoladas e Camadas de VPC</a>",
 "message.ovf.configurations": "H\u00e1 propriedades OVF dispon\u00edveis para 
a personaliza\u00e7\u00e3o do aparelho selecionado. Por favor, edite os valores 
de forma apropriada.As ofertas incompat\u00edveis de computa\u00e7\u00e3o 
ser\u00e3o desativadas.",
@@ -3391,6 +3408,7 @@
 "message.read.accept.license.agreements": "Leia e aceite os termos dos 
contratos de licen\u00e7a.",
 "message.read.admin.guide.scaling.up": "Por favor leia a sess\u00e3o sobre 
escalonamento din\u00e2mico no guia do administrador antes de escalonar.",
 "message.recover.vm": "Por favor, confirme a recupera\u00e7\u00e3o desta VM.",
+"message.register.keypair.failed": "Falha ao registrar par de chave de API",
 "message.reinstall.vm": "NOTA: proceda com cuidado. Isso far\u00e1 com que a 
m\u00e1quina virtual seja re-instalada a partir do template. Todos os dados do 
disco root ser\u00e3o perdidos. Se houver volumes de dados adicionais, eles 
n\u00e3o ser\u00e3o alterados.",
 "message.release.ip.failed": "Falha ao liberar IP",
 "message.releasing.dedicated.cluster": "Liberando cluster dedicado...",
@@ -3542,7 +3560,6 @@
 "message.success.create.keypair": "Par de chaves SSH criado com sucesso",
 "message.success.create.kubernetes.cluster": "Cluster Kubernetes criado com 
sucesso",
 "message.success.create.l2.network": "Rede L2 criada com sucesso",
-"message.success.create.password": "Par de chave de acesso \u00e0 API criada 
com sucesso",
 "message.success.create.sharedfs": "Sistema de Arquivos Compartilhado criado 
com sucesso",
 "message.success.create.snapshot.from.vmsnapshot": "Snapshot de volume a 
partir da snapshot de VM criada com sucesso",
 "message.success.create.template": "Template criado com sucesso",
@@ -3586,7 +3603,7 @@
 "message.success.register.keypair": "Par de chaves SSH registrado com sucesso",
 "message.success.register.template": "Template cadastrado com sucesso",
 "message.success.register.user.data": "Userdata registrado com sucesso",
-"message.success.register.user.keypair": "Novo par de chave de API criado com 
sucesso",
+"message.success.register.user.keypair": "Novo par de chaves de API registrado 
com sucesso para o usu\u00e1rio \"{user}\"",
 "message.success.release.dedicated.bgp.peer": "Par BGP dedicado liberado com 
sucesso",
 "message.success.release.dedicated.ipv4.subnet": "Sub-rede IPv4 dedicada 
liberada com sucesso",
 "message.success.release.ip": "IP liberado com sucesso",
diff --git a/ui/src/components/view/ApiKeyPairsTab.vue 
b/ui/src/components/view/ApiKeyPairsTab.vue
new file mode 100644
index 00000000000..87feda59d99
--- /dev/null
+++ b/ui/src/components/view/ApiKeyPairsTab.vue
@@ -0,0 +1,451 @@
+// 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"
+        :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"
+      @refresh-data="handleRefreshData"
+      @close-modal="closeModalAddKeyPair" />
+  </div>
+</template>
+<script>
+import { getAPI, postAPI } from '@/api'
+import TooltipButton from '@/components/widgets/TooltipButton'
+import BulkActionView from '@/components/view/BulkActionView.vue'
+import eventBus from '@/config/eventBus'
+import GenerateApiKeyPair from '@/views/iam/GenerateApiKeyPair.vue'
+
+export default {
+  name: 'ApiKeyPairsTab',
+  components: {
+    TooltipButton,
+    BulkActionView,
+    GenerateApiKeyPair
+  },
+  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: this.$t('state.inprogress'), value: 'InProgress' },
+          { text: this.$t('label.success'), value: 'success' },
+          { text: this.$t('label.failed'), value: 'failed' }
+        ]
+      })
+      if (this.selectedRowKeys.length > 0) {
+        this.showGroupActionModal = true
+      }
+      this.deleteKeypairs(this.selectedItems)
+    },
+    async deleteKeypairs (keypairs) {
+      if (!keypairs || keypairs.length === 0) {
+        this.fetchLoading = false
+        return
+      }
+
+      this.fetchLoading = true
+      try {
+        await Promise.all(keypairs.map(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
+      }
+    },
+    async deleteKeyPair (args) {
+      const response = await postAPI('deleteUserKeys', args)
+      return response.deleteuserkeysresponse.jobid
+    },
+    bulkActionConfirmation () {
+      this.showConfirmationAction = true
+      this.selectedColumns = this.columns.filter(column => {
+        return !this.filterColumns.includes(column.title)
+      })
+      this.selectedItems = this.selectedItems.map(v => ({ ...v, status: 
'InProgress' }))
+    },
+    handleCancelBulk () {
+      eventBus.emit('update-bulk-job-status', { items: this.selectedItems, 
action: false })
+      this.showGroupActionModal = false
+      this.selectedItems = []
+      this.selectedColumns = []
+      this.selectedRowKeys = []
+      this.parentFetchData()
+    },
+    closeModalBulk () {
+      this.showConfirmationAction = false
+    },
+    closeModalAddKeyPair () {
+      this.showAddKeyPair = false
+    },
+    handleRefreshData () {
+      this.$emit('refresh-data')
+    },
+    rowSelection () {
+      if ('deleteUserKeys' in this.$store.getters.apis) {
+        return {
+          selectedRowKeys: this.selectedRowKeys,
+          onChange: this.setSelection
+        }
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.list {
+  max-height: 95vh;
+  width: 95vw;
+  overflow-y: scroll;
+  margin: -24px;
+
+  @media (min-width: 1000px) {
+    max-height: 70vh;
+    width: 900px;
+  }
+
+  &__header,
+  &__footer {
+    padding: 20px;
+  }
+
+  &__header {
+    display: flex;
+
+    .ant-select {
+      min-width: 200px;
+    }
+
+    &__col {
+
+      &:not(:last-child) {
+        margin-right: 20px;
+      }
+
+      &--full {
+        flex: 1;
+      }
+
+    }
+
+  }
+
+  &__footer {
+    display: flex;
+    justify-content: flex-end;
+
+    button {
+      &:not(:last-child) {
+        margin-right: 10px;
+      }
+    }
+  }
+
+  &__item {
+    padding-right: 20px;
+    padding-left: 20px;
+
+    &--selected {
+      background-color: #e6f7ff;
+    }
+
+  }
+
+  &__title {
+    font-weight: bold;
+  }
+
+  &__outer-container {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+  }
+
+  &__container {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    cursor: pointer;
+
+    @media (min-width: 760px) {
+      flex-direction: row;
+      align-items: center;
+    }
+
+  }
+
+  &__row {
+    margin-bottom: 10px;
+
+    @media (min-width: 760px) {
+      margin-right: 20px;
+      margin-bottom: 0;
+    }
+  }
+
+  &__radio {
+    display: flex;
+    justify-content: flex-end;
+  }
+
+}
+</style>
diff --git a/ui/src/components/view/DetailsTab.vue 
b/ui/src/components/view/DetailsTab.vue
index 4145eeb9be6..ecff34b2949 100644
--- a/ui/src/components/view/DetailsTab.vue
+++ b/ui/src/components/view/DetailsTab.vue
@@ -97,7 +97,7 @@
             <span v-if="['USER.LOGIN', 'USER.LOGOUT', 'ROUTER.HEALTH.CHECKS', 
'FIREWALL.CLOSE', 
'ALERT.SERVICE.DOMAINROUTER'].includes(dataResource[item])">{{ 
$t(dataResource[item].toLowerCase()) }}</span>
             <span v-else>{{ dataResource[item] }}</span>
           </div>
-          <div v-else-if="['created', 'sent', 'lastannotated', 
'collectiontime', 'lastboottime', 'lastserverstart', 'lastserverstop', 
'removed', 'effectiveDate', 'endDate'].includes(item)">
+          <div v-else-if="['created', 'sent', 'lastannotated', 
'collectiontime', 'lastboottime', 'lastserverstart', 'lastserverstop', 
'removed', 'effectiveDate', 'endDate', 'startdate', 'enddate'].includes(item)">
             {{ $toLocaleDate(dataResource[item]) }}
           </div>
           <div style="white-space: pre-wrap;" v-else-if="$route.meta.name === 
'quotatariff' && item === 'description'">{{ dataResource[item] }}</div>
@@ -168,7 +168,7 @@
           <div>{{ dataResource[item] }}</div>
         </div>
       </a-list-item>
-      <a-list-item v-else-if="['startdate', 'enddate'].includes(item)">
+      <a-list-item v-else-if="['startdate', 'enddate'].includes(item) && 
dataResource[item]">
         <div>
           <strong>{{ $t('label.' + item.replace('date', 
'.date.and.time'))}}</strong>
           <br/>
diff --git a/ui/src/components/view/InfoCard.vue 
b/ui/src/components/view/InfoCard.vue
index 3094bf58dbc..41c822de238 100644
--- a/ui/src/components/view/InfoCard.vue
+++ b/ui/src/components/view/InfoCard.vue
@@ -120,12 +120,18 @@
 
             <a-divider/>
 
-            <div class="resource-detail-item" v-if="(resource.state || 
resource.status) && $route.meta.name !== 'zone'">
+            <div class="resource-detail-item" v-if="(resource.state || 
resource.status) && !['zone', 'keypair'].includes($route.meta.name)">
               <div class="resource-detail-item__label">{{ $t('label.status') 
}}</div>
               <div class="resource-detail-item__details">
                 <status class="status" :text="resource.state || 
resource.status" displayText/>
               </div>
             </div>
+            <div class="resource-detail-item" v-if="resource.apikeyaccess && 
$route.meta.name === 'accountuser'">
+              <div class="resource-detail-item__label">{{ 
$t('label.apikeyaccess') }}</div>
+              <div class="resource-detail-item__details">
+                <status class="status" :text="resource.apikeyaccess" 
displayText/>
+              </div>
+            </div>
             <div class="resource-detail-item" v-if="resource.allocationstate">
               <div class="resource-detail-item__label">{{ 
$t('label.allocationstate') }}</div>
               <div class="resource-detail-item__details">
@@ -159,6 +165,42 @@
                 <span style="margin-left: 10px;"><copy-label 
:label="resource.id" /></span>
               </div>
             </div>
+            <div class="resource-detail-item" v-if="resource.apikey && 
resource.secretkey">
+              <div class="user-keys">
+                <key-outlined />
+                <strong>
+                  {{ $t('label.apikey') }}
+                  <tooltip-button
+                    tooltipPlacement="right"
+                    :tooltip="$t('label.copy') + ' ' + $t('label.apikey')"
+                    icon="CopyOutlined"
+                    type="dashed"
+                    size="small"
+                    @onClick="$message.success($t('label.copied.clipboard'))"
+                    :copyResource="resource.apikey" />
+                </strong>
+                <div>
+                  {{ resource.apikey.substring(0, 20) }}...
+                </div>
+              </div> <br/>
+              <div class="user-keys">
+                <lock-outlined />
+                <strong>
+                  {{ $t('label.secretkey') }}
+                  <tooltip-button
+                    tooltipPlacement="right"
+                    :tooltip="$t('label.copy') + ' ' + $t('label.secretkey')"
+                    icon="CopyOutlined"
+                    type="dashed"
+                    size="small"
+                    @onClick="$message.success($t('label.copied.clipboard'))"
+                    :copyResource="resource.secretkey" />
+                </strong>
+                <div>
+                  {{ resource.secretkey.substring(0, 20) }}...
+                </div>
+              </div>
+            </div>
             <div class="resource-detail-item" v-if="(resource.ostypename || 
resource.osdisplayname) && resource.ostypeid">
               <div class="resource-detail-item__label">{{ 
$t('label.ostypename') }}</div>
               <div class="resource-detail-item__details">
@@ -796,6 +838,14 @@
                 <span v-else>{{ resource.account }}</span>
               </div>
             </div>
+            <div class="resource-detail-item" v-if="resource.userid && 
$route.meta.name === 'keypair'">
+              <div class="resource-detail-item__label">{{ $t('label.user') 
}}</div>
+              <div class="resource-detail-item__details">
+                <user-outlined />
+                <router-link v-if="!isStatic && 
$router.resolve('/accountuser/' + resource.userid).matched[0].redirect !== 
'/exception/404'" :to="{ path: '/accountuser/' + resource.userid }">{{ 
resource.username }}</router-link>
+                <span v-else>{{ resource.username }}</span>
+              </div>
+            </div>
             <div class="resource-detail-item" v-if="resource.roleid">
               <div class="resource-detail-item__label">{{ $t('label.role') 
}}</div>
               <div class="resource-detail-item__details">
@@ -883,54 +933,6 @@
               :osCategoryId="osCategoryId" />
           </div>
 
-          <div class="account-center-tags" v-if="showKeys || 
resource.apikeyaccess">
-            <a-divider/>
-          </div>
-          <div class="account-center-tags" v-if="resource.apikeyaccess && 
resource.account">
-            <div class="resource-detail-item">
-              <div class="resource-detail-item__label">{{ 
$t('label.apikeyaccess') }}</div>
-              <div class="resource-detail-item__details">
-                <status class="status" :text="resource.apikeyaccess" 
displayText/>
-              </div>
-            </div>
-          </div>
-          <div class="account-center-tags" v-if="showKeys">
-            <div class="user-keys">
-              <key-outlined />
-              <strong>
-                {{ $t('label.apikey') }}
-                <tooltip-button
-                  tooltipPlacement="right"
-                  :tooltip="$t('label.copy') + ' ' + $t('label.apikey')"
-                  icon="CopyOutlined"
-                  type="dashed"
-                  size="small"
-                  @onClick="$message.success($t('label.copied.clipboard'))"
-                  :copyResource="resource.apikey" />
-              </strong>
-              <div>
-                {{ resource.apikey.substring(0, 20) }}...
-              </div>
-            </div> <br/>
-            <div class="user-keys">
-              <lock-outlined />
-              <strong>
-                {{ $t('label.secretkey') }}
-                <tooltip-button
-                  tooltipPlacement="right"
-                  :tooltip="$t('label.copy') + ' ' + $t('label.secretkey')"
-                  icon="CopyOutlined"
-                  type="dashed"
-                  size="small"
-                  @onClick="$message.success($t('label.copied.clipboard'))"
-                  :copyResource="resource.secretkey" />
-              </strong>
-              <div>
-                {{ resource.secretkey.substring(0, 20) }}...
-              </div>
-            </div>
-          </div>
-
           <div class="account-center-tags" v-if="!isStatic && resourceType && 
tagsSupportingResourceTypes.includes(this.resourceType) && 'listTags' in 
$store.getters.apis">
             <a-divider/>
             <a-spin :spinning="loadingTags">
@@ -1077,7 +1079,7 @@ export default {
         this.setData()
         this.validLinks = await validateLinksAsync(this.$router, 
this.isStatic, this.resource)
 
-        if ('apikey' in this.resource) {
+        if (this.$route.meta.name === 'accountuser' && 'apikey' in 
this.resource) {
           this.getUserKeys()
         }
         this.updateResourceAdditionalData()
@@ -1256,8 +1258,6 @@ export default {
         return
       }
       getAPI('getUserKeys', { id: this.resource.id }).then(json => {
-        this.showKeys = true
-        this.newResource.secretkey = 
json.getuserkeysresponse.userkeys.secretkey
         if (!this.isAdmin()) {
           this.newResource.apikeyaccess = 
json.getuserkeysresponse.userkeys.apikeyaccess ? 'Enabled' : 'Disabled'
         }
diff --git a/ui/src/components/view/ListView.vue 
b/ui/src/components/view/ListView.vue
index b03293efaca..56fe109099d 100644
--- a/ui/src/components/view/ListView.vue
+++ b/ui/src/components/view/ListView.vue
@@ -795,7 +795,7 @@
         {{ record.enabled ? 'Enabled' : 'Disabled' }}
       </template>
       <template
-        v-if="['created', 'sent', 'removed', 'effectiveDate', 'endDate', 
'allocated'].includes(column.key) || (['startdate'].includes(column.key) && 
['webhook'].includes($route.path.split('/')[1])) || (column.key === 'allocated' 
&& ['asnumbers', 'publicip', 'ipv4subnets'].includes($route.meta.name) && text)"
+        v-if="['created', 'sent', 'removed', 'effectiveDate', 'endDate', 
'allocated', 'startdate', 'enddate'].includes(column.key) || 
(['startdate'].includes(column.key) && 
['webhook'].includes($route.path.split('/')[1])) || (column.key === 'allocated' 
&& ['asnumbers', 'publicip', 'ipv4subnets'].includes($route.meta.name) && text)"
       >
         {{ text && $toLocaleDate(text) }}
       </template>
diff --git a/ui/src/config/router.js b/ui/src/config/router.js
index 43e8efd7b5d..78346d13cac 100644
--- a/ui/src/config/router.js
+++ b/ui/src/config/router.js
@@ -31,6 +31,7 @@ import image from '@/config/section/image'
 import project from '@/config/section/project'
 import event from '@/config/section/event'
 import user from '@/config/section/user'
+import keyPair from '@/config/section/keypair'
 import account from '@/config/section/account'
 import domain from '@/config/section/domain'
 import role from '@/config/section/role'
@@ -221,6 +222,7 @@ export function asyncRouterMap () {
       generateRouterMap(event),
       generateRouterMap(project),
       generateRouterMap(user),
+      generateRouterMap(keyPair),
       generateRouterMap(role),
       generateRouterMap(account),
       generateRouterMap(domain),
diff --git a/ui/src/config/section/keypair.js b/ui/src/config/section/keypair.js
new file mode 100644
index 00000000000..86486db19c3
--- /dev/null
+++ b/ui/src/config/section/keypair.js
@@ -0,0 +1,69 @@
+// 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.
+
+import { shallowRef, defineAsyncComponent } from 'vue'
+import store from '@/store'
+
+export default {
+  name: 'keypair',
+  identifier: 'keypairid',
+  title: 'label.apikeypairs',
+  icon: 'key-outlined',
+  hidden: true,
+  docHelp: 'adminguide/accounts.html#keypairs',
+  permission: ['listUserKeys'],
+  columns: [
+    'name',
+    { field: 'startdate', customTitle: 'apikeypair.startdate' },
+    { field: 'enddate', customTitle: 'apikeypair.enddate' },
+    'username', 'rolename'
+  ],
+  details: [
+    'id', 'name', 'description',
+    'domain', 'role', 'roletype',
+    { field: 'accountname', customTitle: 'account' }, 'username',
+    { field: 'startdate', customTitle: 'apikeypair.startdate' },
+    { field: 'enddate', customTitle: 'apikeypair.enddate' },
+    'created'
+  ],
+  tabs: [{
+    name: 'details',
+    component: shallowRef(defineAsyncComponent(() => 
import('@/components/view/DetailsTab.vue')))
+  }, {
+    name: 'rules',
+    component: shallowRef(defineAsyncComponent(() => 
import('@/views/iam/ApiKeyPairPermissionTable.vue'))),
+    show: () => { return 'listUserKeyRules' in store.getters.apis }
+  }],
+  actions: [
+    {
+      api: 'deleteUserKeys',
+      icon: 'delete-outlined',
+      label: 'label.action.delete.keypair',
+      message: 'message.delete.keypair',
+      dataView: true,
+      args: ['keypairid'],
+      mapping: {
+        keypairid: {
+          value: (record) => { return record.id }
+        }
+      },
+      show: () => {
+        return 'deleteUserKeys' in store.getters.apis
+      }
+    }
+  ]
+}
diff --git a/ui/src/config/section/user.js b/ui/src/config/section/user.js
index eaaca983dc0..a2808356233 100644
--- a/ui/src/config/section/user.js
+++ b/ui/src/config/section/user.js
@@ -63,6 +63,12 @@ export default {
       resourceType: 'User',
       component: shallowRef(defineAsyncComponent(() => 
import('@/components/view/EventsTab.vue'))),
       show: () => { return 'listEvents' in store.getters.apis }
+    },
+    {
+      name: 'apikeypairs',
+      resourceType: 'User',
+      component: shallowRef(defineAsyncComponent(() => 
import('@/components/view/ApiKeyPairsTab.vue'))),
+      show: () => { return 'listUserKeys' in store.getters.apis }
     }
   ],
   actions: [
diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue
index 436f61a37da..c0603445b57 100644
--- a/ui/src/views/AutogenView.vue
+++ b/ui/src/views/AutogenView.vue
@@ -1119,7 +1119,6 @@ export default {
       this.loading = true
       if (this.$route.path.startsWith('/cniconfiguration')) {
         params.forcks = true
-        console.log('here')
       }
       if (this.$route.params && this.$route.params.id) {
         params.id = this.$route.params.id
@@ -1132,6 +1131,10 @@ export default {
             params.name = this.$route.params.id
           }
         }
+        if (['listUserKeys'].includes(this.apiName)) {
+          delete params.listall
+          params.keypairid = this.$route.params.id
+        }
         if (['listPublicIpAddresses'].includes(this.apiName)) {
           params.allocatedonly = false
         }
@@ -1253,7 +1256,7 @@ export default {
         if (this.items.length <= 0 && this.dataView) {
           this.$router.push({ path: '/exception/404' })
         }
-        if (!this.showAction || this.dataView) {
+        if (!this.showAction || this.dataView || (this.items.length === 1 && 
this.apiName === 'getUserKeys')) {
           this.resource = this.items?.[0] || {}
           this.$emit('change-resource', this.resource)
         }
diff --git a/ui/src/views/iam/ApiKeyPairPermissionTable.vue 
b/ui/src/views/iam/ApiKeyPairPermissionTable.vue
new file mode 100644
index 00000000000..a3670aa5578
--- /dev/null
+++ b/ui/src/views/iam/ApiKeyPairPermissionTable.vue
@@ -0,0 +1,518 @@
+// 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')" />
+        </div>
+        <div class="rules-table__col rules-table__col--permission">
+          <permission-editable
+            :default-value="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 TooltipButton from '@/components/widgets/TooltipButton'
+import { genericCompare } from '@/utils/sort'
+
+export default {
+  name: 'ApiKeyPairPermissionTable',
+  components: {
+    PermissionEditable,
+    draggable,
+    TooltipButton
+  },
+  props: {
+    resource: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      loadingTable: true,
+      disabled: false,
+      rules: [],
+      newRule: '',
+      newRulePermission: 'allow',
+      newRuleDescription: '',
+      drag: false,
+      apis: [],
+      currRules: new Set(),
+      searchText: '',
+      searchedColumn: '',
+      columns: [
+        {
+          title: this.$t('label.rule'),
+          dataIndex: 'rule',
+          key: 'rule',
+          ellipsis: true,
+          customFilterDropdown: true,
+          onFilter: (value, record) => {
+            return 
record.rule.toString().toLowerCase().includes(value.toLowerCase())
+          },
+          sorter: (a, b) => { return genericCompare(a.rule || '', b.rule || 
'') },
+          width: 480
+        },
+        {
+          title: this.$t('label.permission'),
+          dataIndex: 'permission',
+          key: 'permission',
+          width: 160,
+          align: 'center',
+          sorter: (a, b) => { return genericCompare(a.permission || '', 
b.permission || '') }
+        },
+        {
+          title: this.$t('label.description'),
+          dataIndex: 'description',
+          key: 'description'
+        }
+      ],
+      pagination: {
+        pageSize: this.$store.getters.defaultListViewPageSize,
+        pageSizeOptions: ['10', '20', '40', '80', '100', '200'],
+        showSizeChanger: true
+      },
+      autocompleteKey: 0
+    }
+  },
+  async created () {
+    if (this.$route.path.startsWith('/keypair')) {
+      await this.fetchKeyData()
+      this.disabled = true
+    } else {
+      this.getApis()
+    }
+    this.loadingTable = false
+  },
+  methods: {
+    handleSearch (selectedKeys, confirm, dataIndex) {
+      confirm()
+      this.searchText = selectedKeys[0]
+      this.searchedColumn = dataIndex
+    },
+    handleReset (clearFilters) {
+      clearFilters()
+      this.searchText = ''
+    },
+    handlePaginationChange (pagination) {
+      this.pagination.pageSize = pagination.pageSize
+      this.pagination.current = pagination.current
+    },
+    filterOption (input, option) {
+      return option.value.toUpperCase().indexOf(input.toUpperCase()) >= 0
+    },
+    async fetchKeyData () {
+      try {
+        const response = await getAPI('listUserKeyRules', { keypairid: 
this.resource.id })
+        this.rules = response?.listuserkeyrulesresponse?.keypermission ?? []
+      } catch (e) {
+        this.$notifyError(e)
+      }
+    },
+    getApis () {
+      this.apis = Object.keys(this.$store.getters.apis).sort((a, b) => 
a.localeCompare(b)).map(value => { return { value: value } })
+    },
+    onRuleDelete (rule, idx) {
+      this.rules.splice(idx, 1)
+      this.currRules.delete(rule)
+      this.updateRules()
+    },
+    onRuleSave () {
+      if (!this.newRule || this.currRules.has(this.newRule)) {
+        return
+      }
+      this.rules.push({
+        rule: this.newRule,
+        permission: this.newRulePermission,
+        description: this.newRuleDescription,
+        roleid: this.resource.id
+      })
+      this.currRules.add(this.newRule)
+      this.newRule = ''
+      this.newRuleDescription = ''
+      this.autocompleteKey += 1
+      this.updateRules()
+    },
+    async deleteAllRules () {
+      this.loadingTable = true
+      return new Promise((resolve) => {
+        setTimeout(() => {
+          this.rules = []
+          this.currRules = new Set()
+          this.updateRules()
+          resolve()
+          this.loadingTable = false
+        })
+      })
+    },
+    updateRules () {
+      this.$emit('update-rules', this.rules)
+    },
+    updateNewPermission () {
+      this.newRulePermission = (this.newRulePermission === 'allow') ? 'deny' : 
'allow'
+    },
+    onPermissionChange (record, value, idx) {
+      if (!record) return
+      this.rules[idx].permission = value
+      record.permission = value
+      this.updateRules()
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.main-loading-spinner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 30px;
+}
+.role-add-btn {
+  margin-bottom: 15px;
+}
+.new-role-controls {
+  display: flex;
+
+  button {
+    &:not(:last-child) {
+      margin-right: 5px;
+    }
+  }
+}
+
+.rules-list {
+  max-height: 600px;
+  overflow: auto;
+
+  &--overflow-hidden {
+    overflow: hidden;
+  }
+}
+
+.rules-table {
+  &-item {
+    position: relative;
+    display: flex;
+    align-items: stretch;
+    padding: 0;
+    flex-wrap: wrap;
+
+    @media (min-width: 760px) {
+      flex-wrap: nowrap;
+      padding-right: 25px;
+    }
+  }
+
+  &__col {
+    display: flex;
+    align-items: center;
+    padding: 15px;
+
+    @media (min-width: 760px) {
+      padding: 15px 0;
+
+      &:not(:first-child) {
+        padding-left: 20px;
+      }
+
+      &:not(:last-child) {
+        border-right: 1px solid #e8e8e8;
+        padding-right: 20px;
+      }
+    }
+
+    &--grab {
+      position: absolute;
+      top: 4px;
+      left: 0;
+      width: 100%;
+
+      @media (min-width: 760px) {
+        position: relative;
+        top: auto;
+        width: 35px;
+        padding-left: 25px;
+        justify-content: center;
+      }
+    }
+
+    &--rule,
+    &--description {
+      word-break: break-all;
+      flex: 1;
+      width: 100%;
+
+      @media (min-width: 760px) {
+        width: auto;
+      }
+    }
+
+    &--rule {
+      padding-left: 60px;
+      background-color: rgba(#e6f7ff, 0.7);
+
+      @media (min-width: 760px) {
+        padding-left: 20px;
+        background: none;
+      }
+    }
+
+    &--permission {
+      justify-content: center;
+      width: 100%;
+
+      .ant-select {
+        width: 100%;
+      }
+
+      @media (min-width: 760px) {
+        width: auto;
+
+        .ant-select {
+          width: auto;
+        }
+      }
+    }
+
+    &--actions {
+      max-width: 60px;
+      width: 100%;
+      padding-right: 0;
+
+      @media (min-width: 760px) {
+        width: auto;
+        max-width: 70px;
+        padding-right: 15px;
+      }
+    }
+
+    &--new {
+      padding-left: 15px;
+      background-color: transparent;
+
+      div {
+        width: 100%;
+      }
+    }
+  }
+}
+
+.no-description {
+  opacity: 0.4;
+  font-size: 0.7rem;
+
+  @media (min-width: 760px) {
+    display: none;
+  }
+
+}
+
+.drag-handle {
+  cursor: pointer;
+}
+
+.drag-ghost {
+  opacity: 0.5;
+  background: #f0f2f5;
+}
+
+.loading-overlay {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 5;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 3rem;
+  color: #39A7DE;
+  background-color: rgba(#fff, 0.8);
+}
+
+.permission-tag {
+  border: none;
+  border-radius: 999px;
+  padding: 2px 10px;
+  font-size: 14px;
+  font-weight: 500;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.rules-table__col--new {
+  .ant-select {
+    width: 100%;
+  }
+}
+
+.rule-dropdown-error {
+  .ant-input {
+    border-color: #ff0000
+  }
+}
+</style>
diff --git a/ui/src/views/iam/GenerateApiKeyPair.vue 
b/ui/src/views/iam/GenerateApiKeyPair.vue
new file mode 100644
index 00000000000..bc0b2bb475e
--- /dev/null
+++ b/ui/src/views/iam/GenerateApiKeyPair.vue
@@ -0,0 +1,226 @@
+// 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({})
+    },
+    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) {
+      if (e && typeof e.preventDefault === 'function') {
+        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', { 
user: this.resource.username }),
+            successMethod: () => {
+              this.fetchData()
+            },
+            errorMessage: this.$t('message.register.keypair.failed'),
+            errorMethod: () => {
+              this.fetchData()
+            },
+            loadingMessage: this.$t('label.registering.keypair', { user: 
this.resource.username }),
+            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 = null
+      this.form.description = null
+      this.form.startDate = null
+      this.form.endDate = null
+      this.rules = []
+      this.$emit('close-modal')
+    },
+    fetchData () {
+      this.$emit('fetch-data')
+    },
+    updateRules (rules) {
+      this.rules = rules
+    },
+    disabledStartDate (current) {
+      return current < dayjs().startOf('day')
+    },
+    disabledEndDate (current) {
+      return current < (this.form.startDate || dayjs().startOf('day'))
+    }
+  }
+}
+</script>
+
+<style scoped lang="less">
+.form-layout {
+  width: 80vw;
+
+  @media (min-width: 600px) {
+    width: 450px;
+  }
+}
+</style>

Reply via email to