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>