diff --git a/ui/app/adapters/keymgmt/key.js b/ui/app/adapters/keymgmt/key.js
new file mode 100644
index 0000000000..2046d096a7
--- /dev/null
+++ b/ui/app/adapters/keymgmt/key.js
@@ -0,0 +1,152 @@
+import ApplicationAdapter from '../application';
+import { encodePath } from 'vault/utils/path-encoding-helpers';
+
+function pickKeys(obj, picklist) {
+ const data = {};
+ Object.keys(obj).forEach((key) => {
+ if (picklist.indexOf(key) >= 0) {
+ data[key] = obj[key];
+ }
+ });
+ return data;
+}
+export default class KeymgmtKeyAdapter extends ApplicationAdapter {
+ namespace = 'v1';
+
+ url(backend, id, type) {
+ const url = `${this.buildURL()}/${backend}/key`;
+ if (id) {
+ if (type === 'ROTATE') {
+ return url + '/' + encodePath(id) + '/rotate';
+ } else if (type === 'PROVIDERS') {
+ return url + '/' + encodePath(id) + '/kms';
+ }
+ return url + '/' + encodePath(id);
+ }
+ return url;
+ }
+
+ urlForDeleteRecord(store, type, snapshot) {
+ const name = snapshot.attr('name');
+ const backend = snapshot.attr('backend');
+ return this.url(backend, name);
+ }
+
+ _updateKey(backend, name, serialized) {
+ // Only these two attributes are allowed to be updated
+ let data = pickKeys(serialized, ['deletion_allowed', 'min_enabled_version']);
+ return this.ajax(this.url(backend, name), 'PUT', { data });
+ }
+
+ _createKey(backend, name, serialized) {
+ // Only type is allowed on create
+ let data = pickKeys(serialized, ['type']);
+ return this.ajax(this.url(backend, name), 'POST', { data });
+ }
+
+ async createRecord(store, type, snapshot) {
+ const data = store.serializerFor(type.modelName).serialize(snapshot);
+ const name = snapshot.attr('name');
+ const backend = snapshot.attr('backend');
+ // Keys must be created and then updated
+ await this._createKey(backend, name, data);
+ if (snapshot.attr('deletionAllowed')) {
+ try {
+ await this._updateKey(backend, name, data);
+ } catch (e) {
+ // TODO: Test how this works with UI
+ throw new Error(`Key ${name} was created, but not all settings were saved`);
+ }
+ }
+ return {
+ data: {
+ ...data,
+ id: name,
+ backend,
+ },
+ };
+ }
+
+ updateRecord(store, type, snapshot) {
+ const data = store.serializerFor(type.modelName).serialize(snapshot);
+ const name = snapshot.attr('name');
+ const backend = snapshot.attr('backend');
+ return this._updateKey(backend, name, data);
+ }
+
+ distribute(backend, kms, key, data) {
+ return this.ajax(`${this.buildURL()}/${backend}/kms/${encodePath(kms)}/key/${encodePath(key)}`, 'PUT', {
+ data: { ...data },
+ });
+ }
+
+ async getProvider(backend, name) {
+ try {
+ const resp = await this.ajax(this.url(backend, name, 'PROVIDERS'), 'GET', {
+ data: {
+ list: true,
+ },
+ });
+ return resp.data.keys ? resp.data.keys[0] : null;
+ } catch (e) {
+ if (e.httpStatus === 404) {
+ // No results, not distributed yet
+ return null;
+ } else if (e.httpStatus === 403) {
+ return { permissionsError: true };
+ }
+ // TODO: handle control group
+ throw e;
+ }
+ }
+
+ getDistribution(backend, kms, key) {
+ const url = `${this.buildURL()}/${backend}/kms/${kms}/key/${key}`;
+ return this.ajax(url, 'GET')
+ .then((res) => {
+ return {
+ ...res.data,
+ purposeArray: res.data.purpose.split(','),
+ };
+ })
+ .catch(() => {
+ // TODO: handle control group
+ return null;
+ });
+ }
+
+ async queryRecord(store, type, query) {
+ const { id, backend, recordOnly = false } = query;
+ const keyData = await this.ajax(this.url(backend, id), 'GET');
+ keyData.data.id = id;
+ keyData.data.backend = backend;
+ let provider, distribution;
+ if (!recordOnly) {
+ provider = await this.getProvider(backend, id);
+ if (provider) {
+ distribution = await this.getDistribution(backend, provider, id);
+ }
+ }
+ return { ...keyData, provider, distribution };
+ }
+
+ async query(store, type, query) {
+ const { backend, provider } = query;
+ const providerAdapter = store.adapterFor('keymgmt/provider');
+ const url = provider ? providerAdapter.buildKeysURL(query) : this.url(backend);
+
+ return this.ajax(url, 'GET', {
+ data: {
+ list: true,
+ },
+ }).then((res) => {
+ res.backend = backend;
+ return res;
+ });
+ }
+
+ rotateKey(backend, id) {
+ // TODO: re-fetch record data after
+ return this.ajax(this.url(backend, id, 'ROTATE'), 'PUT');
+ }
+}
diff --git a/ui/app/adapters/keymgmt/provider.js b/ui/app/adapters/keymgmt/provider.js
new file mode 100644
index 0000000000..8de168cce7
--- /dev/null
+++ b/ui/app/adapters/keymgmt/provider.js
@@ -0,0 +1,63 @@
+import ApplicationAdapter from '../application';
+import { all } from 'rsvp';
+
+export default class KeymgmtKeyAdapter extends ApplicationAdapter {
+ namespace = 'v1';
+ listPayload = { data: { list: true } };
+
+ pathForType() {
+ // backend name prepended in buildURL method
+ return 'kms';
+ }
+ buildURL(modelName, id, snapshot, requestType, query) {
+ let url = super.buildURL(...arguments);
+ if (snapshot) {
+ url = url.replace('kms', `${snapshot.attr('backend')}/kms`);
+ } else if (query) {
+ url = url.replace('kms', `${query.backend}/kms`);
+ }
+ return url;
+ }
+ buildKeysURL(query) {
+ const url = this.buildURL('keymgmt/provider', null, null, 'query', query);
+ return `${url}/${query.provider}/key`;
+ }
+ async createRecord(store, { modelName }, snapshot) {
+ // create uses PUT instead of POST
+ const data = store.serializerFor(modelName).serialize(snapshot);
+ const url = this.buildURL(modelName, snapshot.attr('name'), snapshot, 'updateRecord');
+ return this.ajax(url, 'PUT', { data }).then(() => data);
+ }
+ findRecord(store, type, name) {
+ return super.findRecord(...arguments).then((resp) => {
+ resp.data = { ...resp.data, name };
+ return resp;
+ });
+ }
+ async query(store, type, query) {
+ const url = this.buildURL(type.modelName, null, null, 'query', query);
+ return this.ajax(url, 'GET', this.listPayload).then(async (resp) => {
+ // additional data is needed to fullfil the list view requirements
+ // pull in full record for listed items
+ const records = await all(
+ resp.data.keys.map((name) => this.findRecord(store, type, name, this._mockSnapshot(query.backend)))
+ );
+ resp.data.keys = records.map((record) => record.data);
+ return resp;
+ });
+ }
+ async queryRecord(store, type, query) {
+ return this.findRecord(store, type, query.id, this._mockSnapshot(query.backend));
+ }
+
+ // when using find in query or queryRecord overrides snapshot is not available
+ // ultimately buildURL requires the snapshot to pull the backend name for the dynamic segment
+ // since we have the backend value from the query generate a mock snapshot
+ _mockSnapshot(backend) {
+ return {
+ attr(prop) {
+ return prop === 'backend' ? backend : null;
+ },
+ };
+ }
+}
diff --git a/ui/app/components/keymgmt/distribute.js b/ui/app/components/keymgmt/distribute.js
new file mode 100644
index 0000000000..a138ad31d9
--- /dev/null
+++ b/ui/app/components/keymgmt/distribute.js
@@ -0,0 +1,247 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { KEY_TYPES } from '../../models/keymgmt/key';
+
+/**
+ * @module KeymgmtDistribute
+ * KeymgmtDistribute components are used to provide a form to distribute Keymgmt keys to a provider.
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @param {string} backend - name of backend, which will be the basis of other store queries
+ * @param {string} [key] - key is the name of the existing key which is being distributed. Will hide the key field in UI
+ * @param {string} [provider] - provider is the name of the existing provider which is being distributed to. Will hide the provider field in UI
+ */
+
+class DistributionData {
+ @tracked key;
+ @tracked provider;
+ @tracked operations;
+ @tracked protection;
+}
+
+const VALID_TYPES_BY_PROVIDER = {
+ gcpckms: ['aes256-gcm96', 'rsa-2048', 'rsa-3072', 'rsa-4096', 'ecdsa-p256', 'ecdsa-p384', 'ecdsa-p521'],
+ awskms: ['aes256-gcm96'],
+ azurekeyvault: ['rsa-2048', 'rsa-3072', 'rsa-4096'],
+};
+export default class KeymgmtDistribute extends Component {
+ @service store;
+ @service flashMessages;
+ @service router;
+
+ @tracked keyModel;
+ @tracked isNewKey = false;
+ @tracked providerType;
+ @tracked formData;
+
+ constructor() {
+ super(...arguments);
+ this.formData = new DistributionData();
+ // Set initial values passed in
+ this.formData.key = this.args.key || '';
+ this.formData.provider = this.args.provider || '';
+ // Side effects to get types of key or provider passed in
+ if (this.args.provider) {
+ this.getProviderType(this.args.provider);
+ }
+ if (this.args.key) {
+ this.getKeyInfo(this.args.key);
+ }
+ this.formData.operations = [];
+ }
+
+ get keyTypes() {
+ return KEY_TYPES;
+ }
+
+ get validMatchError() {
+ if (!this.providerType || !this.keyModel?.type) {
+ return null;
+ }
+ const valid = VALID_TYPES_BY_PROVIDER[this.providerType]?.includes(this.keyModel.type);
+ if (valid) return null;
+
+ // default to showing error on provider unless @provider (field hidden)
+ if (this.args.provider) {
+ return {
+ key: `This key type is incompatible with the ${this.providerType} provider. To distribute to this provider, change the key type or choose another key.`,
+ };
+ }
+
+ const message = `This provider is incompatible with the ${this.keyModel.type} key type. Please choose another provider`;
+ return {
+ provider: this.args.key ? `${message}.` : `${message} or change the key type.`,
+ };
+ }
+
+ get operations() {
+ const pt = this.providerType;
+ if (pt === 'awskms') {
+ return ['encrypt', 'decrypt'];
+ } else if (pt === 'gcpckms') {
+ const kt = this.keyModel?.type || '';
+ switch (kt) {
+ case 'aes256-gcm96':
+ return ['encrypt', 'decrypt'];
+ case 'rsa-2048':
+ case 'rsa-3072':
+ case 'rsa-4096':
+ return ['decrypt', 'sign'];
+ case 'ecdsa-p256':
+ case 'ecdsa-p384':
+ return ['sign'];
+ default:
+ return ['encrypt', 'decrypt', 'sign', 'verify', 'wrap', 'unwrap'];
+ }
+ }
+
+ return ['encrypt', 'decrypt', 'sign', 'verify', 'wrap', 'unwrap'];
+ }
+
+ get disableOperations() {
+ return (
+ this.validMatchError ||
+ !this.formData.provider ||
+ !this.formData.key ||
+ (this.isNewKey && !this.keyModel.type)
+ );
+ }
+
+ async getKeyInfo(keyName, isNew = false) {
+ let key;
+ if (isNew) {
+ this.isNewKey = true;
+ key = this.store.createRecord(`keymgmt/key`, {
+ backend: this.args.backend,
+ id: keyName,
+ name: keyName,
+ });
+ } else {
+ key = await this.store
+ .queryRecord(`keymgmt/key`, {
+ backend: this.args.backend,
+ id: keyName,
+ recordOnly: true,
+ })
+ .catch(() => {
+ // Key type isn't essential for distributing, so if
+ // we can't read it for some reason swallow the error
+ // and allow the API to respond with any key/provider
+ // type matching errors
+ });
+ }
+ this.keyModel = key;
+ }
+
+ async getProviderType(id) {
+ if (!id) {
+ this.providerType = '';
+ return;
+ }
+
+ const provider = await this.store
+ .queryRecord('keymgmt/provider', {
+ backend: this.args.backend,
+ id,
+ })
+ .catch(() => {});
+ this.providerType = provider?.provider;
+ }
+
+ destroyKey() {
+ if (this.isNewKey) {
+ // Delete record from store if it was created here
+ this.keyModel.destroyRecord().finally(() => {
+ this.keyModel = null;
+ });
+ }
+ this.isNewKey = false;
+ this.keyModel = null;
+ }
+
+ /**
+ *
+ * @param {DistributionData} rawData
+ * @returns POJO formatted how the distribution endpoint needs
+ */
+ formatData(rawData) {
+ const { key, provider, operations, protection } = rawData;
+ if (!key || !provider || !operations || operations.length === 0) return null;
+ return { key, provider, purpose: operations.join(','), protection };
+ }
+
+ distributeKey(backend, kms, key, data) {
+ let adapter = this.store.adapterFor('keymgmt/key');
+ return adapter
+ .distribute(backend, kms, key, data)
+ .then(() => {
+ this.flashMessages.success(`Successfully distributed key ${key} to ${kms}`);
+ this.router.transitionTo('vault.cluster.secrets.backend.show', key);
+ })
+ .catch((e) => {
+ this.flashMessages.danger(`Error distributing key: ${e.errors}`);
+ });
+ }
+
+ @action
+ handleProvider(evt) {
+ this.formData.provider = evt.target.value;
+ if (evt.target.value) {
+ this.getProviderType(evt.target.value);
+ }
+ }
+ @action
+ handleKeyType(evt) {
+ this.keyModel.set('type', evt.target.value);
+ }
+
+ @action
+ handleOperation(evt) {
+ const ops = [...this.formData.operations];
+ if (evt.target.checked) {
+ ops.push(evt.target.id);
+ } else {
+ const idx = ops.indexOf(evt.target.id);
+ ops.splice(idx, 1);
+ }
+ this.formData.operations = ops;
+ }
+
+ @action
+ async handleKeySelect(selected) {
+ const selectedKey = selected[0] || null;
+ if (!selectedKey) {
+ this.formData.key = null;
+ return this.destroyKey();
+ }
+ this.formData.key = selectedKey.id;
+ return this.getKeyInfo(selectedKey.id, selectedKey.isNew);
+ }
+
+ @action
+ async createDistribution(evt) {
+ evt.preventDefault();
+ const { backend } = this.args;
+ const data = this.formatData(this.formData);
+ if (!data) {
+ this.flashMessages.danger(`Key, provider, and operations are all required`);
+ return;
+ }
+ if (this.isNewKey) {
+ this.keyModel
+ .save()
+ .then(() => {
+ this.flashMessages.success(`Successfully created key ${this.keyModel.name}`);
+ })
+ .catch((e) => {
+ this.flashMessages.danger(`Error creating new key ${this.keyModel.name}: ${e.errors}`);
+ });
+ }
+ this.distributeKey(backend, 'example-kms', 'example-key', this.formatData(this.formData));
+ }
+}
diff --git a/ui/app/components/keymgmt/key-edit.js b/ui/app/components/keymgmt/key-edit.js
new file mode 100644
index 0000000000..74531ba0ef
--- /dev/null
+++ b/ui/app/components/keymgmt/key-edit.js
@@ -0,0 +1,98 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
+/**
+ * @module KeymgmtKeyEdit
+ * KeymgmtKeyEdit components are used to display KeyMgmt Secrets engine UI for Key items
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @param {object} model - model is the data from the store
+ * @param {string} [mode=show] - mode controls which view is shown on the component
+ * @param {string} [tab=details] - Options are "details" or "versions" for the show mode only
+ */
+
+const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
+const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
+export default class KeymgmtKeyEdit extends Component {
+ @service store;
+ @service router;
+ @service flashMessages;
+ @tracked isDeleteModalOpen = false;
+
+ get mode() {
+ return this.args.mode || 'show';
+ }
+
+ get keyAdapter() {
+ return this.store.adapterFor('keymgmt/key');
+ }
+
+ @action
+ toggleModal(bool) {
+ this.isDeleteModalOpen = bool;
+ }
+
+ @action
+ createKey(evt) {
+ evt.preventDefault();
+ this.args.model.save();
+ }
+
+ @action
+ updateKey(evt) {
+ evt.preventDefault();
+ const name = this.args.model.name;
+ this.args.model
+ .save()
+ .then(() => {
+ this.router.transitionTo(SHOW_ROUTE, name);
+ })
+ .catch((e) => {
+ this.flashMessages.danger(e.errors.join('. '));
+ });
+ }
+
+ @action
+ removeKey(id) {
+ // TODO: remove action
+ console.log('remove', id);
+ }
+
+ @action
+ deleteKey() {
+ const secret = this.args.model;
+ const backend = secret.backend;
+ console.log({ secret });
+ secret
+ .destroyRecord()
+ .then(() => {
+ try {
+ this.router.transitionTo(LIST_ROOT_ROUTE, backend, { queryParams: { tab: 'key' } });
+ } catch (e) {
+ console.debug(e);
+ }
+ })
+ .catch((e) => {
+ this.flashMessages.danger(e.errors?.join('. '));
+ });
+ }
+
+ @action
+ rotateKey(id) {
+ const backend = this.args.model.get('backend');
+ const adapter = this.keyAdapter;
+ adapter
+ .rotateKey(backend, id)
+ .then(() => {
+ this.flashMessages.success(`Success: ${id} connection was rotated`);
+ })
+ .catch((e) => {
+ this.flashMessages.danger(e.errors);
+ });
+ }
+}
diff --git a/ui/app/components/keymgmt/provider-edit.js b/ui/app/components/keymgmt/provider-edit.js
new file mode 100644
index 0000000000..35b6be8ae6
--- /dev/null
+++ b/ui/app/components/keymgmt/provider-edit.js
@@ -0,0 +1,97 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+import { task } from 'ember-concurrency';
+import { waitFor } from '@ember/test-waiters';
+
+/**
+ * @module KeymgmtProviderEdit
+ * ProviderKeyEdit components are used to display KeyMgmt Secrets engine UI for Key items
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @param {object} model - model is the data from the store
+ * @param {string} mode - mode controls which view is shown on the component - show | create |
+ * @param {string} [tab] - Options are "details" or "keys" for the show mode only
+ */
+
+export default class KeymgmtProviderEdit extends Component {
+ @service router;
+ @service flashMessages;
+
+ constructor() {
+ super(...arguments);
+ // key count displayed in details tab and keys are listed in keys tab
+ if (this.args.mode === 'show') {
+ this.fetchKeys.perform();
+ }
+ }
+
+ @tracked modelValidations;
+
+ get isShowing() {
+ return this.args.mode === 'show';
+ }
+ get isCreating() {
+ return this.args.mode === 'create';
+ }
+ get viewingKeys() {
+ return this.args.tab === 'keys';
+ }
+
+ @task
+ @waitFor
+ *saveTask() {
+ const { model } = this.args;
+ try {
+ yield model.save();
+ this.router.transitionTo('vault.cluster.secrets.backend.show', model.id, {
+ queryParams: { itemType: 'provider' },
+ });
+ } catch (error) {
+ this.flashMessages.danger(error.errors.join('. '));
+ }
+ }
+ @task
+ @waitFor
+ *fetchKeys(page = 1) {
+ try {
+ yield this.args.model.fetchKeys(page);
+ } catch (error) {
+ this.flashMessages.danger(error.errors.join('. '));
+ }
+ }
+
+ @action
+ async onSave(event) {
+ event.preventDefault();
+ const { isValid, state } = await this.args.model.validate();
+ if (isValid) {
+ this.saveTask.perform();
+ } else {
+ this.modelValidations = state;
+ }
+ }
+ @action
+ async onDelete() {
+ try {
+ const { model, root } = this.args;
+ await model.destroyRecord();
+ this.router.transitionTo(root.path, root.model, { queryParams: { tab: 'provider' } });
+ } catch (error) {
+ this.flashMessages.danger(error.errors.join('. '));
+ }
+ }
+ @action
+ async onDeleteKey(model) {
+ try {
+ await model.destroyRecord();
+ this.args.model.keys.removeObject(model);
+ } catch (error) {
+ this.flashMessages.danger(error.errors.join('. '));
+ }
+ }
+}
diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js
index d91af0b93d..b23a7ffc5f 100644
--- a/ui/app/components/mount-backend-form.js
+++ b/ui/app/components/mount-backend-form.js
@@ -4,7 +4,7 @@ import { computed } from '@ember/object';
import Component from '@ember/component';
import { task } from 'ember-concurrency';
import { methods } from 'vault/helpers/mountable-auth-methods';
-import { engines, KMIP, TRANSFORM } from 'vault/helpers/mountable-secret-engines';
+import { engines, KMIP, TRANSFORM, KEYMGMT } from 'vault/helpers/mountable-secret-engines';
import { waitFor } from '@ember/test-waiters';
const METHODS = methods();
@@ -65,7 +65,7 @@ export default Component.extend({
engines: computed('version.{features[],isEnterprise}', function () {
if (this.version.isEnterprise) {
- return ENGINES.concat([KMIP, TRANSFORM]);
+ return ENGINES.concat([KMIP, TRANSFORM, KEYMGMT]);
}
return ENGINES;
}),
diff --git a/ui/app/components/pagination-controls.js b/ui/app/components/pagination-controls.js
new file mode 100644
index 0000000000..de52c5e8da
--- /dev/null
+++ b/ui/app/components/pagination-controls.js
@@ -0,0 +1,57 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+/**
+ * @module PaginationControls
+ * PaginationControls components are used to paginate through item lists
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @param {number} total - total number of items
+ * @param {number} [startPage=1] - initial page number to select
+ * @param {number} [size=15] - number of items to display per page
+ * @param {function} onChange - callback fired on page change
+ */
+
+export default class PaginationControls extends Component {
+ @tracked page;
+
+ constructor() {
+ super(...arguments);
+ this.page = this.args.startPage || 1;
+ this.size = this.args.size || 15; // size selector may be added in future version
+ }
+
+ get totalPages() {
+ return Math.ceil(this.args.total / this.size);
+ }
+ get displayInfo() {
+ const { total } = this.args;
+ const end = this.page * this.size;
+ return `${end - this.size + 1}-${end > total ? total : end} of ${total}`;
+ }
+ get pages() {
+ // show 5 pages with 2 on either side of the current page
+ let start = this.page - 2 >= 1 ? this.page - 2 : 1;
+ const incrementer = start + 4;
+ const end = incrementer <= this.totalPages ? incrementer : this.totalPages;
+ const pageNumbers = [];
+ while (start <= end) {
+ pageNumbers.push(start);
+ start++;
+ }
+ return pageNumbers;
+ }
+ get hasMorePages() {
+ return this.pages.lastObject !== this.totalPages;
+ }
+
+ @action
+ changePage(page) {
+ this.page = page;
+ this.args.onChange(page);
+ }
+}
diff --git a/ui/app/controllers/vault/cluster/secrets/backend/edit.js b/ui/app/controllers/vault/cluster/secrets/backend/edit.js
index f7eb5bd848..8f97308472 100644
--- a/ui/app/controllers/vault/cluster/secrets/backend/edit.js
+++ b/ui/app/controllers/vault/cluster/secrets/backend/edit.js
@@ -3,10 +3,13 @@ import BackendCrumbMixin from 'vault/mixins/backend-crumb';
export default Controller.extend(BackendCrumbMixin, {
backendController: controller('vault.cluster.secrets.backend'),
- queryParams: ['version'],
+ queryParams: ['version', 'itemType'],
version: '',
+ itemType: '',
+
reset() {
this.set('version', '');
+ this.set('itemType', '');
},
actions: {
refresh: function () {
diff --git a/ui/app/controllers/vault/cluster/secrets/backend/show.js b/ui/app/controllers/vault/cluster/secrets/backend/show.js
index b96ff32dc3..79925aa3be 100644
--- a/ui/app/controllers/vault/cluster/secrets/backend/show.js
+++ b/ui/app/controllers/vault/cluster/secrets/backend/show.js
@@ -3,14 +3,16 @@ import BackendCrumbMixin from 'vault/mixins/backend-crumb';
export default Controller.extend(BackendCrumbMixin, {
backendController: controller('vault.cluster.secrets.backend'),
- queryParams: ['tab', 'version', 'type'],
+ queryParams: ['tab', 'version', 'type', 'itemType', 'page'],
version: '',
tab: '',
type: '',
+ itemType: '',
reset() {
this.set('tab', '');
this.set('version', '');
this.set('type', '');
+ this.set('itemType', '');
},
actions: {
refresh: function () {
diff --git a/ui/app/decorators/model-validations.js b/ui/app/decorators/model-validations.js
index 4cf91310ee..fae6e8f70d 100644
--- a/ui/app/decorators/model-validations.js
+++ b/ui/app/decorators/model-validations.js
@@ -1,15 +1,19 @@
/* eslint-disable no-console */
import validators from 'vault/utils/validators';
+import { get } from '@ember/object';
/**
* used to validate properties on a class
*
* decorator expects validations object with the following shape:
- * { [propertyKeyName]: [{ type, options, message }] }
+ * { [propertyKeyName]: [{ type, options, message, validator }] }
* each key in the validations object should refer to the property on the class to apply the validation to
* type refers to the type of validation to apply -- must be exported from validators util for lookup
* options is an optional object for given validator -- min, max, nullable etc. -- see validators in util
* message is added to the errors array and returned from the validate method if validation fails
+ * validator may be used in place of type to provide a function that gets executed in the validate method
+ * validator is useful when specific validations are needed (dependent on other class properties etc.)
+ * validator must be passed as function that takes the class context (this) as the only argument and returns true or false
* each property supports multiple validations provided as an array -- for example, presence and length for string
*
* validations must be invoked using the validate method which is added directly to the decorated class
@@ -21,7 +25,7 @@ import validators from 'vault/utils/validators';
* errors will be populated with messages defined in the validations object when validations fail
* since a property can have multiple validations, errors is always returned as an array
*
- * full example
+ *** basic example
*
* import Model from '@ember-data/model';
* import withModelValidations from 'vault/decorators/model-validations';
@@ -35,6 +39,18 @@ import validators from 'vault/utils/validators';
* -> isValid = false;
* -> state.foo.isValid = false;
* -> state.foo.errors = ['foo is a required field'];
+ *
+ *** example using custom validator
+ *
+ * const validations = { foo: [{ validator: (model) => model.bar.includes('test') ? model.foo : false, message: 'foo is required if bar includes test' }] };
+ * @withModelValidations(validations)
+ * class SomeModel extends Model { foo = false; bar = ['foo', 'baz']; }
+ *
+ * const model = new SomeModel();
+ * const { isValid, state } = model.validate();
+ * -> isValid = false;
+ * -> state.foo.isValid = false;
+ * -> state.foo.errors = ['foo is required if bar includes test'];
*/
export function withModelValidations(validations) {
@@ -67,16 +83,25 @@ export function withModelValidations(validations) {
state[key] = { errors: [] };
for (const rule of rules) {
- const { type, options, message } = rule;
- if (!validators[type]) {
+ const { type, options, message, validator: customValidator } = rule;
+ // check for custom validator or lookup in validators util by type
+ const useCustomValidator = typeof customValidator === 'function';
+ const validator = useCustomValidator ? customValidator : validators[type];
+ if (!validator) {
console.error(
- `Validator type: "${type}" not found. Available validators: ${Object.keys(validators).join(
- ', '
- )}`
+ !type
+ ? 'Validator not found. Define either type or pass custom validator function under "validator" key in validations object'
+ : `Validator type: "${type}" not found. Available validators: ${Object.keys(
+ validators
+ ).join(', ')}`
);
continue;
}
- if (!validators[type](this[key], options)) {
+ const passedValidation = useCustomValidator
+ ? validator(this)
+ : validator(get(this, key), options); // dot notation may be used to define key for nested property
+
+ if (!passedValidation) {
// consider setting a prop like validationErrors directly on the model
// for now return an errors object
state[key].errors.push(message);
diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js
index eb0bec23e8..94b2a8b72f 100644
--- a/ui/app/helpers/mountable-secret-engines.js
+++ b/ui/app/helpers/mountable-secret-engines.js
@@ -16,6 +16,15 @@ export const TRANSFORM = {
requiredFeature: 'Transform Secrets Engine',
};
+export const KEYMGMT = {
+ displayName: 'Key Management',
+ value: 'keymgmt',
+ type: 'keymgmt',
+ glyph: 'key',
+ category: 'generic',
+ requiredFeature: 'Key Management Secrets Engine',
+};
+
const MOUNTABLE_SECRET_ENGINES = [
{
displayName: 'Active Directory',
diff --git a/ui/app/helpers/options-for-backend.js b/ui/app/helpers/options-for-backend.js
index e918e60c51..88e457b793 100644
--- a/ui/app/helpers/options-for-backend.js
+++ b/ui/app/helpers/options-for-backend.js
@@ -83,6 +83,31 @@ const SECRET_BACKENDS = {
},
],
},
+ keymgmt: {
+ displayName: 'Key Management',
+ navigateTree: false,
+ listItemPartial: 'secret-list/item',
+ tabs: [
+ {
+ name: 'key',
+ label: 'Keys',
+ searchPlaceholder: 'Filter keys',
+ item: 'key',
+ create: 'Create key',
+ editComponent: 'keymgmt/key-edit',
+ },
+ {
+ name: 'provider',
+ modelPrefix: 'provider/',
+ label: 'Providers',
+ searchPlaceholder: 'Filter providers',
+ item: 'provider',
+ create: 'Create provider',
+ tab: 'provider',
+ editComponent: 'keymgmt/provider-edit',
+ },
+ ],
+ },
transform: {
displayName: 'Transformation',
navigateTree: false,
diff --git a/ui/app/helpers/secret-query-params.js b/ui/app/helpers/secret-query-params.js
index be7a873f65..f0a5b63d0d 100644
--- a/ui/app/helpers/secret-query-params.js
+++ b/ui/app/helpers/secret-query-params.js
@@ -1,13 +1,19 @@
import { helper } from '@ember/component/helper';
-export function secretQueryParams([backendType, type = '']) {
- if (backendType === 'transit') {
- return { tab: 'actions' };
+export function secretQueryParams([backendType, type = ''], { asQueryParams }) {
+ const values = {
+ transit: { tab: 'actions' },
+ database: { type },
+ keymgmt: { itemType: type || 'key' },
+ }[backendType];
+ // format required when using LinkTo with positional params
+ if (values && asQueryParams) {
+ return {
+ isQueryParams: true,
+ values,
+ };
}
- if (backendType === 'database') {
- return { type: type };
- }
- return;
+ return values;
}
export default helper(secretQueryParams);
diff --git a/ui/app/helpers/sub.js b/ui/app/helpers/sub.js
new file mode 100644
index 0000000000..990f3bdbee
--- /dev/null
+++ b/ui/app/helpers/sub.js
@@ -0,0 +1,5 @@
+import { helper } from '@ember/component/helper';
+
+export default helper(function ([a, ...toSubtract]) {
+ return toSubtract.reduce((total, value) => total - parseInt(value, 0), a);
+});
diff --git a/ui/app/helpers/supported-secret-backends.js b/ui/app/helpers/supported-secret-backends.js
index 3adb3d32c2..806b90513b 100644
--- a/ui/app/helpers/supported-secret-backends.js
+++ b/ui/app/helpers/supported-secret-backends.js
@@ -11,6 +11,7 @@ const SUPPORTED_SECRET_BACKENDS = [
'transit',
'kmip',
'transform',
+ 'keymgmt',
];
export function supportedSecretBackends() {
diff --git a/ui/app/models/keymgmt/key.js b/ui/app/models/keymgmt/key.js
new file mode 100644
index 0000000000..2c7ce63547
--- /dev/null
+++ b/ui/app/models/keymgmt/key.js
@@ -0,0 +1,96 @@
+import Model, { attr } from '@ember-data/model';
+import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
+
+export const KEY_TYPES = [
+ 'aes256-gcm96',
+ 'rsa-2048',
+ 'rsa-3072',
+ 'rsa-4096',
+ 'ecdsa-p256',
+ 'ecdsa-p384',
+ 'ecdsa-p521',
+];
+export default class KeymgmtKeyModel extends Model {
+ @attr('string') name;
+ @attr('string') backend;
+
+ @attr('string', {
+ possibleValues: KEY_TYPES,
+ })
+ type;
+
+ @attr('boolean', {
+ defaultValue: false,
+ })
+ deletionAllowed;
+
+ @attr('number', {
+ label: 'Current version',
+ })
+ latestVersion;
+
+ @attr('number', {
+ defaultValue: 0,
+ defaultShown: 'All versions enabled',
+ })
+ minEnabledVersion;
+
+ @attr('array')
+ versions;
+
+ // The following are calculated in serializer
+ @attr('date')
+ created;
+
+ @attr('date', {
+ defaultShown: 'Not yet rotated',
+ })
+ lastRotated;
+
+ // The following are from endpoints other than the main read one
+ @attr() provider; // string, or object with permissions error
+ @attr() distribution;
+
+ icon = 'key';
+
+ get hasVersions() {
+ return this.versions.length > 1;
+ }
+
+ get createFields() {
+ const createFields = ['name', 'type', 'deletionAllowed'];
+ return expandAttributeMeta(this, createFields);
+ }
+
+ get updateFields() {
+ return expandAttributeMeta(this, ['minEnabledVersion', 'deletionAllowed']);
+ }
+ get showFields() {
+ return expandAttributeMeta(this, [
+ 'name',
+ 'created',
+ 'type',
+ 'deletionAllowed',
+ 'latestVersion',
+ 'minEnabledVersion',
+ 'lastRotated',
+ ]);
+ }
+
+ get keyTypeOptions() {
+ return expandAttributeMeta(this, ['type'])[0];
+ }
+
+ get distFields() {
+ return [
+ {
+ name: 'name',
+ type: 'string',
+ label: 'Distributed name',
+ subText: 'The name given to the key by the provider.',
+ },
+ { name: 'purpose', type: 'string', label: 'Key Purpose' },
+ { name: 'protection', type: 'string', subText: 'Where cryptographic operations are performed.' },
+ ];
+ }
+}
diff --git a/ui/app/models/keymgmt/provider.js b/ui/app/models/keymgmt/provider.js
new file mode 100644
index 0000000000..1dbdda6421
--- /dev/null
+++ b/ui/app/models/keymgmt/provider.js
@@ -0,0 +1,121 @@
+import Model, { attr } from '@ember-data/model';
+import { tracked } from '@glimmer/tracking';
+import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
+import { withModelValidations } from 'vault/decorators/model-validations';
+
+const CRED_PROPS = {
+ azurekeyvault: ['client_id', 'client_secret', 'tenant_id'],
+ awskms: ['access_key', 'secret_key', 'session_token', 'endpoint'],
+ gcpckms: ['service_account_file'],
+};
+const OPTIONAL_CRED_PROPS = ['session_token', 'endpoint'];
+// since we have dynamic credential attributes based on provider we need a dynamic presence validator
+// add validators for all cred props and return true for value if not associated with selected provider
+const credValidators = Object.keys(CRED_PROPS).reduce((obj, providerKey) => {
+ CRED_PROPS[providerKey].forEach((prop) => {
+ if (!OPTIONAL_CRED_PROPS.includes(prop)) {
+ obj[`credentials.${prop}`] = [
+ {
+ message: `${prop} is required`,
+ validator(model) {
+ return model.credentialProps.includes(prop) ? model.credentials[prop] : true;
+ },
+ },
+ ];
+ }
+ });
+ return obj;
+}, {});
+const validations = {
+ name: [{ type: 'presence', message: 'Provider name is required' }],
+ keyCollection: [{ type: 'presence', message: 'Key Vault instance name' }],
+ ...credValidators,
+};
+@withModelValidations(validations)
+export default class KeymgmtProviderModel extends Model {
+ @attr('string') backend;
+ @attr('string', {
+ label: 'Provider name',
+ subText: 'This is the name of the provider that will be displayed in Vault. This cannot be edited later.',
+ })
+ name;
+
+ @attr('string', {
+ label: 'Type',
+ subText: 'Choose the provider type.',
+ possibleValues: ['azurekeyvault', 'awskms', 'gcpckms'],
+ defaultValue: 'azurekeyvault',
+ })
+ provider;
+
+ @attr('string', {
+ label: 'Key Vault instance name',
+ subText: 'The name of a Key Vault instance must be supplied. This cannot be edited later.',
+ })
+ keyCollection;
+
+ @attr('date') created;
+
+ idPrefix = 'provider/';
+ type = 'provider';
+
+ @tracked keys = [];
+ @tracked credentials = null; // never returned from API -- set only during create/edit
+
+ get icon() {
+ return {
+ azurekeyvault: 'azure-color',
+ awskms: 'aws-color',
+ gcpckms: 'gcp-color',
+ }[this.provider];
+ }
+ get typeName() {
+ return {
+ azurekeyvault: 'Azure Key Vault',
+ awskms: 'AWS Key Management Service',
+ gcpckms: 'Google Cloud Key Management Service',
+ }[this.provider];
+ }
+ get showFields() {
+ const attrs = expandAttributeMeta(this, ['name', 'created', 'keyCollection']);
+ attrs.splice(1, 0, { hasBlock: true, label: 'Type', value: this.typeName, icon: this.icon });
+ const l = this.keys.length;
+ const value = l ? `${l} ${l > 1 ? 'keys' : 'key'}` : 'None';
+ attrs.push({ hasBlock: true, isLink: l, label: 'Keys', value });
+ return attrs;
+ }
+ get credentialProps() {
+ return CRED_PROPS[this.provider];
+ }
+ get credentialFields() {
+ const [creds, fields] = this.credentialProps.reduce(
+ ([creds, fields], prop) => {
+ creds[prop] = null;
+ fields.push({ name: `credentials.${prop}`, type: 'string', options: { label: prop } });
+ return [creds, fields];
+ },
+ [{}, []]
+ );
+ this.credentials = creds;
+ return fields;
+ }
+ get createFields() {
+ return expandAttributeMeta(this, ['provider', 'name', 'keyCollection']);
+ }
+
+ async fetchKeys(page) {
+ try {
+ this.keys = await this.store.lazyPaginatedQuery('keymgmt/key', {
+ backend: 'keymgmt',
+ provider: this.name,
+ responsePath: 'data.keys',
+ page,
+ });
+ } catch (error) {
+ this.keys = [];
+ if (error.httpStatus !== 404) {
+ throw error;
+ }
+ }
+ }
+}
diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js
index b30b01b3d2..a51983fd8f 100644
--- a/ui/app/models/secret-engine.js
+++ b/ui/app/models/secret-engine.js
@@ -75,15 +75,10 @@ export default SecretEngineModel.extend({
formFields: computed('engineType', 'options.version', function () {
let type = this.engineType;
let version = this.options?.version;
- let fields = [
- 'type',
- 'path',
- 'description',
- 'accessor',
- 'local',
- 'sealWrap',
- 'config.{defaultLeaseTtl,maxLeaseTtl,auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}',
- ];
+ let fields = ['type', 'path', 'description', 'accessor', 'local', 'sealWrap'];
+ // no ttl options for keymgmt
+ const ttl = type !== 'keymgmt' ? 'defaultLeaseTtl,maxLeaseTtl,' : '';
+ fields.push(`config.{${ttl}auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}`);
if (type === 'kv' || type === 'generic') {
fields.push('options.{version}');
}
@@ -104,14 +99,14 @@ export default SecretEngineModel.extend({
defaultGroup = { default: ['path'] };
}
let optionsGroup = {
- 'Method Options': [
- 'description',
- 'config.listingVisibility',
- 'local',
- 'sealWrap',
- 'config.{defaultLeaseTtl,maxLeaseTtl,auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}',
- ],
+ 'Method Options': ['description', 'config.listingVisibility', 'local', 'sealWrap'],
};
+ // no ttl options for keymgmt
+ const ttl = type !== 'keymgmt' ? 'defaultLeaseTtl,maxLeaseTtl,' : '';
+ optionsGroup['Method Options'].push(
+ `config.{${ttl}auditNonHmacRequestKeys,auditNonHmacResponseKeys,passthroughRequestHeaders}`
+ );
+
if (type === 'kv' || type === 'generic') {
optionsGroup['Method Options'].unshift('options.{version}');
}
@@ -142,6 +137,16 @@ export default SecretEngineModel.extend({
return fieldToAttrs(this, this.formFieldGroups);
}),
+ icon: computed('engineType', function () {
+ if (!this.engineType || this.engineType === 'kmip') {
+ return 'secrets';
+ }
+ if (this.engineType === 'keymgmt') {
+ return 'key';
+ }
+ return this.engineType;
+ }),
+
// namespaces introduced types with a `ns_` prefix for built-in engines
// so we need to strip that to normalize the type
engineType: computed('type', function () {
diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js
index 6dac9d9cdb..6362d30cc2 100644
--- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js
+++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js
@@ -31,7 +31,7 @@ export default EditBase.extend({
wizard: service(),
createModel(transition) {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
- let modelType = this.modelType(backend);
+ let modelType = this.modelType(backend, null, { queryParams: transition.to.queryParams });
if (modelType === 'role-ssh') {
return this.store.createRecord(modelType, { keyType: 'ca' });
}
diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js
index 6245fce39a..019d4b3ad3 100644
--- a/ui/app/routes/vault/cluster/secrets/backend/list.js
+++ b/ui/app/routes/vault/cluster/secrets/backend/list.js
@@ -84,6 +84,7 @@ export default Route.extend({
// secret or secret-v2
cubbyhole: 'secret',
kv: secretEngine.get('modelTypeForKV'),
+ keymgmt: `keymgmt/${tab || 'key'}`,
generic: secretEngine.get('modelTypeForKV'),
};
return types[type];
diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js
index 98d015b23f..10f0d9995f 100644
--- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js
+++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js
@@ -66,9 +66,9 @@ export default Route.extend(UnloadModelRoute, {
templateName: 'vault/cluster/secrets/backend/secretEditLayout',
- beforeModel() {
+ beforeModel({ to: { queryParams } }) {
let secret = this.secretParam();
- return this.buildModel(secret).then(() => {
+ return this.buildModel(secret, queryParams).then(() => {
const parentKey = utils.parentKeyForKey(secret);
const mode = this.routeName.split('.').pop();
if (mode === 'edit' && utils.keyIsFolder(secret)) {
@@ -81,17 +81,16 @@ export default Route.extend(UnloadModelRoute, {
});
},
- buildModel(secret) {
+ buildModel(secret, queryParams) {
const backend = this.enginePathParam();
-
- let modelType = this.modelType(backend, secret);
+ let modelType = this.modelType(backend, secret, { queryParams });
if (['secret', 'secret-v2'].includes(modelType)) {
return resolve();
}
return this.pathHelp.getNewModel(modelType, backend);
},
- modelType(backend, secret) {
+ modelType(backend, secret, options = {}) {
let backendModel = this.modelFor('vault.cluster.secrets.backend', backend);
let type = backendModel.get('engineType');
let types = {
@@ -103,6 +102,7 @@ export default Route.extend(UnloadModelRoute, {
pki: secret && secret.startsWith('cert/') ? 'pki-certificate' : 'role-pki',
cubbyhole: 'secret',
kv: backendModel.get('modelTypeForKV'),
+ keymgmt: `keymgmt/${options.queryParams?.itemType || 'key'}`,
generic: backendModel.get('modelTypeForKV'),
};
return types[type];
@@ -212,10 +212,10 @@ export default Route.extend(UnloadModelRoute, {
return secretModel;
},
- async model(params) {
+ async model(params, { to: { queryParams } }) {
let secret = this.secretParam();
let backend = this.enginePathParam();
- let modelType = this.modelType(backend, secret);
+ let modelType = this.modelType(backend, secret, { queryParams });
let type = params.type || '';
if (!secret) {
secret = '\u0020';
diff --git a/ui/app/serializers/keymgmt/key.js b/ui/app/serializers/keymgmt/key.js
new file mode 100644
index 0000000000..6c8dcba5f7
--- /dev/null
+++ b/ui/app/serializers/keymgmt/key.js
@@ -0,0 +1,34 @@
+import ApplicationSerializer from '../application';
+
+export default class KeymgmtKeySerializer extends ApplicationSerializer {
+ normalizeItems(payload) {
+ let normalized = super.normalizeItems(payload);
+ // Transform versions from object with number keys to array with key ids
+ if (normalized.versions) {
+ let lastRotated;
+ let created;
+ let versions = [];
+ Object.keys(normalized.versions).forEach((key, i, arr) => {
+ versions.push({
+ id: parseInt(key, 10),
+ ...normalized.versions[key],
+ });
+ if (i === 0) {
+ created = normalized.versions[key].creation_time;
+ } else if (arr.length - 1 === i) {
+ // Set lastRotated to the last key
+ lastRotated = normalized.versions[key].creation_time;
+ }
+ });
+ normalized.versions = versions;
+ return { ...normalized, last_rotated: lastRotated, created };
+ } else if (Array.isArray(normalized)) {
+ return normalized.map((key) => ({
+ id: key.id,
+ name: key.id,
+ backend: payload.backend,
+ }));
+ }
+ return normalized;
+ }
+}
diff --git a/ui/app/serializers/keymgmt/provider.js b/ui/app/serializers/keymgmt/provider.js
new file mode 100644
index 0000000000..ac679c273f
--- /dev/null
+++ b/ui/app/serializers/keymgmt/provider.js
@@ -0,0 +1,13 @@
+import ApplicationSerializer from '../application';
+
+export default class KeymgmtProviderSerializer extends ApplicationSerializer {
+ primaryKey = 'name';
+
+ serialize(snapshot) {
+ const json = super.serialize(...arguments);
+ return {
+ ...json,
+ credentials: snapshot.record.credentials,
+ };
+ }
+}
diff --git a/ui/app/styles/components/doc-link.scss b/ui/app/styles/components/doc-link.scss
index d2a2f806a3..dd03711714 100644
--- a/ui/app/styles/components/doc-link.scss
+++ b/ui/app/styles/components/doc-link.scss
@@ -6,3 +6,12 @@
text-decoration: underline !important;
}
}
+
+.doc-link-subtle {
+ color: inherit;
+ text-decoration: underline;
+ font-weight: inherit;
+ &:hover {
+ color: inherit;
+ }
+}
diff --git a/ui/app/styles/core/buttons.scss b/ui/app/styles/core/buttons.scss
index 871de5c9f3..123a0f594e 100644
--- a/ui/app/styles/core/buttons.scss
+++ b/ui/app/styles/core/buttons.scss
@@ -41,6 +41,11 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
min-width: auto;
padding: 0;
}
+ &.is-flat {
+ min-width: auto;
+ border: none;
+ box-shadow: none;
+ }
@each $name, $pair in $colors {
$color: nth($pair, 1);
@@ -100,6 +105,16 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
}
}
+ &.is-underlined {
+ &:active,
+ &.is-active {
+ background-color: transparent;
+ border-bottom: 2px solid darken($color, 10%);
+ border-radius: unset;
+ color: darken($color, 10%);
+ }
+ }
+
&.is-inverted.is-outlined {
border-color: rgba($color-invert, 0.5);
color: rgba($color-invert, 0.9);
@@ -238,6 +253,14 @@ $button-box-shadow-standard: 0 3px 1px 0 rgba($black, 0.12);
width: 100%;
}
+a.button.disabled {
+ color: $white;
+ background-color: $grey-dark;
+ opacity: 0.5;
+ border-color: transparent;
+ box-shadow: none;
+ cursor: default;
+}
.icon-button {
background: transparent;
padding: 0;
diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss
index fb0b5fef92..55d4350df0 100644
--- a/ui/app/styles/core/forms.scss
+++ b/ui/app/styles/core/forms.scss
@@ -326,7 +326,8 @@ fieldset.form-fieldset {
border: none;
}
-.has-error-border {
+.has-error-border,
+select.has-error-border {
border: 1px solid $red-500;
}
diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss
index 27cb0d2440..ffb4c16a01 100644
--- a/ui/app/styles/core/helpers.scss
+++ b/ui/app/styles/core/helpers.scss
@@ -194,6 +194,31 @@
.has-top-margin-xxl {
margin-top: $spacing-xxl;
}
+.has-left-margin-xxs {
+ margin-left: $spacing-xxs;
+}
+.has-left-margin-xs {
+ margin-left: $spacing-xs;
+}
+.has-left-margin-s {
+ margin-left: $spacing-s;
+}
+.has-left-margin-m {
+ margin-left: $spacing-m;
+}
+.has-left-margin-l {
+ margin-left: $spacing-l;
+}
+.has-left-margin-xl {
+ margin-left: $spacing-xl;
+}
+.has-right-margin-l {
+ margin-right: $spacing-l;
+}
+.has-border-top-light {
+ border-radius: 0;
+ border-top: 1px solid $grey-light;
+}
.has-border-bottom-light {
border-radius: 0;
border-bottom: 1px solid $grey-light;
diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss
index 88c78ef528..0c6195e767 100644
--- a/ui/app/styles/core/title.scss
+++ b/ui/app/styles/core/title.scss
@@ -15,6 +15,9 @@
color: $black;
text-decoration: none;
}
+ .has-font-weight-normal {
+ font-weight: $font-weight-normal;
+ }
}
.form-section .title {
diff --git a/ui/app/templates/components/keymgmt/distribute.hbs b/ui/app/templates/components/keymgmt/distribute.hbs
new file mode 100644
index 0000000000..16716045a2
--- /dev/null
+++ b/ui/app/templates/components/keymgmt/distribute.hbs
@@ -0,0 +1,155 @@
+{{#if @backend}}
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/components/keymgmt/key-edit.hbs b/ui/app/templates/components/keymgmt/key-edit.hbs
new file mode 100644
index 0000000000..f09dc3aa0a
--- /dev/null
+++ b/ui/app/templates/components/keymgmt/key-edit.hbs
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+ {{#if (eq @mode "create")}}
+ Create key
+ {{else if (eq @mode "edit")}}
+ Edit key
+ {{else}}
+ {{@model.id}}
+ {{/if}}
+
+
+
+
+{{#if (eq this.mode "show")}}
+
+
+
+
+
+ Details
+
+
+
+
+ Versions
+
+
+
+
+
+
+
+
+ Destroy key
+
+
+ Remove key
+
+
+
+ Rotate key
+
+
+ Edit key
+
+
+
+{{/if}}
+
+{{#if (eq this.mode "create")}}
+
+{{else if (eq this.mode "edit")}}
+
+{{else if (eq @tab "versions")}}
+ {{#each @model.versions as |version|}}
+
+
+
+
+ Version {{version.id}}
+
+
+ {{date-from-now version.creation_time addSuffix=true}}
+
+
+ {{#if (eq @model.minEnabledVersion version.id)}}
+
+ Current mininum enabled version
+ {{/if}}
+
+
+
+ {{/each}}
+{{else}}
+
+
Key Details
+ {{#each @model.showFields as |attr|}}
+
+ {{/each}}
+
+
+
+ Distribution Details
+
+ {{! TODO: Use capabilities to tell if it's not distributed vs no permissions }}
+ {{#if @model.provider.permissionsError}}
+
+ {{else if @model.provider}}
+
+
+ {{@model.provider}}
+
+
+ {{#if @model.distribution}}
+ {{#each @model.distFields as |attr|}}
+
+ {{/each}}
+ {{else}}
+
+ {{/if}}
+ {{else}}
+
+ {{! TODO: Distribute link
+
+ Distribute
+ }}
+
+ {{/if}}
+
+{{/if}}
+
+
+
+ Destroying the
+ {{@model.name}}
+ key means that the underlying data will be lost and the key will become unusable for cryptographic operations. It is
+ unrecoverable.
+
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/keymgmt/provider-edit.hbs b/ui/app/templates/components/keymgmt/provider-edit.hbs
new file mode 100644
index 0000000000..0beee9ce98
--- /dev/null
+++ b/ui/app/templates/components/keymgmt/provider-edit.hbs
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+ {{#if this.isShowing}}
+ Provider
+ {{@model.id}}
+ {{else}}
+ {{if this.isCreating "Create provider" "Update credentials"}}
+ {{/if}}
+
+
+
+
+{{#if this.isShowing}}
+
+
+
+
+
+ Details
+
+
+
+
+ Keys
+
+
+
+
+
+ {{#unless this.viewingKeys}}
+
+
+
+
+
+ Delete provider
+
+
+ {{#if @model.keys.length}}
+
+
+ This provider cannot be deleted until all 20 keys distributed to it are revoked. This can be done from the
+ Keys tab.
+
+
+ {{/if}}
+
+
+ {{! Update once distribute route has been created }}
+ {{!
+ Distribute key
+
+ }}
+
+ Update credentials
+
+
+
+ {{/unless}}
+{{else}}
+
+{{/if}}
+
+{{#if this.isShowing}}
+
+ {{#if this.viewingKeys}}
+ {{#let (options-for-backend "keymgmt" "key") as |options|}}
+ {{#if @model.keys.meta.total}}
+ {{#each @model.keys as |key|}}
+
+ {{/each}}
+ {{#if (gt @model.keys.meta.lastPage 1)}}
+
+ {{/if}}
+ {{else}}
+
+
+ Create key
+
+
+ {{/if}}
+ {{/let}}
+ {{else}}
+ {{#each @model.showFields as |attr|}}
+ {{#if attr.hasBlock}}
+
+ {{#if attr.icon}}
+
+ {{/if}}
+ {{#if attr.isLink}}
+
+ {{attr.value}}
+
+ {{else}}
+ {{attr.value}}
+ {{/if}}
+
+ {{else}}
+
+ {{/if}}
+ {{/each}}
+ {{/if}}
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/components/mount-backend-form.hbs b/ui/app/templates/components/mount-backend-form.hbs
index 852c0be663..b6f003793f 100644
--- a/ui/app/templates/components/mount-backend-form.hbs
+++ b/ui/app/templates/components/mount-backend-form.hbs
@@ -46,8 +46,9 @@
@groupName="mount-type"
@onRadioChange={{queue (action (mut this.mountModel.type)) (action "onTypeChange" "type")}}
@disabled={{if type.requiredFeature (not (has-feature type.requiredFeature)) false}}
+ {{! TODO: verify that keymgmt is in the ADP module }}
@tooltipMessage={{if
- (or (eq type.type "transform") (eq type.type "kmip"))
+ (or (eq type.type "transform") (eq type.type "kmip") (eq type.type "keymgmt"))
(concat
type.displayName
" is part of the Advanced Data Protection module, which is not included in your enterprise license."
diff --git a/ui/app/templates/components/pagination-controls.hbs b/ui/app/templates/components/pagination-controls.hbs
new file mode 100644
index 0000000000..d29300d150
--- /dev/null
+++ b/ui/app/templates/components/pagination-controls.hbs
@@ -0,0 +1,44 @@
+
+
+
+ {{this.displayInfo}}
+
+
+
+
+
+ Previous
+
+ {{#each this.pages as |page|}}
+
+ {{page}}
+
+ {{/each}}
+ {{#if this.hasMorePages}}
+ ...
+ {{/if}}
+
+ Next
+
+
+
+ {{! intentionally empty to place buttons in the middle }}
+
+
\ No newline at end of file
diff --git a/ui/app/templates/components/secret-list-header.hbs b/ui/app/templates/components/secret-list-header.hbs
index 0e1041d936..dbaeeaf43a 100644
--- a/ui/app/templates/components/secret-list-header.hbs
+++ b/ui/app/templates/components/secret-list-header.hbs
@@ -14,7 +14,7 @@
-
+
{{@model.id}}
{{#if this.isKV}}
diff --git a/ui/app/templates/components/secret-list/item.hbs b/ui/app/templates/components/secret-list/item.hbs
index 2b9184bf0c..eac1994553 100644
--- a/ui/app/templates/components/secret-list/item.hbs
+++ b/ui/app/templates/components/secret-list/item.hbs
@@ -6,20 +6,20 @@
class="list-item-row"
data-test-secret-link={{@item.id}}
@encode={{true}}
- @queryParams={{secret-query-params @backendModel.type}}
+ @queryParams={{secret-query-params @backendModel.type @item.type}}
>
{{#if (eq @backendModel.type "transit")}}
{{else}}
-
+
{{/if}}
{{if (eq @item.id " ") "(self)" (or @item.keyWithoutParent @item.id)}}
diff --git a/ui/app/templates/vault/cluster/secrets/backends.hbs b/ui/app/templates/vault/cluster/secrets/backends.hbs
index 53a1fab8c3..f829155309 100644
--- a/ui/app/templates/vault/cluster/secrets/backends.hbs
+++ b/ui/app/templates/vault/cluster/secrets/backends.hbs
@@ -34,7 +34,7 @@
@accessor={{if (eq backend.options.version 2) (concat "v2 " backend.accessor) backend.accessor}}
@description={{backend.description}}
@glyphText={{backend.engineType}}
- @glyph={{or (if (eq backend.engineType "kmip") "secrets" backend.engineType) "secrets"}}
+ @glyph={{backend.icon}}
@link={{hash route=backendLink model=backend.id}}
@title={{backend.path}}
/>
diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js
index a23437695f..f85dcae049 100644
--- a/ui/lib/core/addon/components/form-field.js
+++ b/ui/lib/core/addon/components/form-field.js
@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
-import { action, get } from '@ember/object';
+import { action } from '@ember/object';
import { capitalize } from 'vault/helpers/capitalize';
import { humanize } from 'vault/helpers/humanize';
import { dasherize } from 'vault/helpers/dasherize';
@@ -84,7 +84,7 @@ export default class FormFieldComponent extends Component {
}
get validationError() {
const validations = this.args.modelValidations || {};
- const state = get(validations, this.valuePath);
+ const state = validations[this.valuePath];
return state && !state.isValid ? state.errors.join('. ') : null;
}
diff --git a/ui/lib/core/addon/components/info-table-row.hbs b/ui/lib/core/addon/components/info-table-row.hbs
index 9b296d0727..da508d676e 100644
--- a/ui/lib/core/addon/components/info-table-row.hbs
+++ b/ui/lib/core/addon/components/info-table-row.hbs
@@ -47,6 +47,8 @@
{{else}}
{{/if}}
+ {{else if @formatDate}}
+ {{date-format @value @formatDate}}
{{else}}
{{#if (eq @type "array")}}
option.id));
+ if (this.passObject) {
+ this.onChange(Array.from(this.selectedOptions, (option) => ({ id: option.id, isNew: !!option.new })));
+ } else {
+ this.onChange(Array.from(this.selectedOptions, (option) => option.id));
+ }
} else {
this.onChange(this.selectedOptions);
}
diff --git a/ui/lib/core/addon/helpers/has-feature.js b/ui/lib/core/addon/helpers/has-feature.js
index 86e1be61a5..02afb86aef 100644
--- a/ui/lib/core/addon/helpers/has-feature.js
+++ b/ui/lib/core/addon/helpers/has-feature.js
@@ -15,6 +15,7 @@ const POSSIBLE_FEATURES = [
'Namespaces',
'KMIP',
'Transform Secrets Engine',
+ 'Key Management Secrets Engine',
];
export function hasFeature(featureName, features) {
diff --git a/ui/lib/core/addon/templates/components/alert-inline.hbs b/ui/lib/core/addon/templates/components/alert-inline.hbs
index fcaa2af4b3..57ead04eaf 100644
--- a/ui/lib/core/addon/templates/components/alert-inline.hbs
+++ b/ui/lib/core/addon/templates/components/alert-inline.hbs
@@ -1,4 +1,8 @@
- {{@message}}
+ {{#if (has-block)}}
+ {{yield}}
+ {{else}}
+ {{@message}}
+ {{/if}}
\ No newline at end of file
diff --git a/ui/lib/core/addon/templates/components/search-select.hbs b/ui/lib/core/addon/templates/components/search-select.hbs
index d0795af7da..dbbc5a666e 100644
--- a/ui/lib/core/addon/templates/components/search-select.hbs
+++ b/ui/lib/core/addon/templates/components/search-select.hbs
@@ -80,4 +80,7 @@
{{/each}}
+ {{#if (has-block)}}
+ {{yield}}
+ {{/if}}
{{/if}}
\ No newline at end of file
diff --git a/ui/mirage/handlers/index.js b/ui/mirage/handlers/index.js
index 58b1357380..edf6bfb1a8 100644
--- a/ui/mirage/handlers/index.js
+++ b/ui/mirage/handlers/index.js
@@ -5,5 +5,6 @@ import mfa from './mfa';
import activity from './activity';
import clients from './clients';
import db from './db';
+import kms from './kms';
-export { base, activity, mfa, clients, db };
+export { base, activity, mfa, clients, db, kms };
diff --git a/ui/mirage/handlers/kms.js b/ui/mirage/handlers/kms.js
new file mode 100644
index 0000000000..5bad5ede7a
--- /dev/null
+++ b/ui/mirage/handlers/kms.js
@@ -0,0 +1,58 @@
+export default function (server) {
+ server.get('keymgmt/key?list=true', function () {
+ return {
+ data: {
+ keys: ['example-1', 'example-2', 'example-3'],
+ },
+ };
+ });
+
+ server.get('keymgmt/key/:name', function (_, request) {
+ let name = request.params.name;
+ return {
+ data: {
+ name,
+ deletion_allowed: false,
+ keys: {
+ 1: {
+ creation_time: '2020-11-02T15:54:58.768473-08:00',
+ public_key: '-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----',
+ },
+ 2: {
+ creation_time: '2020-11-04T16:58:47.591718-08:00',
+ public_key: '-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----',
+ },
+ },
+ latest_version: 2,
+ min_enabled_version: 1,
+ type: 'rsa-2048',
+ },
+ };
+ });
+
+ server.get('keymgmt/key/:name/kms', function () {
+ return {
+ data: {
+ keys: ['example-kms'],
+ },
+ };
+ });
+
+ server.post('keymgmt/key/:name', function () {
+ return {};
+ });
+
+ server.put('keymgmt/key/:name', function () {
+ return {};
+ });
+
+ server.get('/keymgmt/kms/:provider/key', () => {
+ const keys = [];
+ let i = 1;
+ while (i <= 75) {
+ keys.push(`testkey-${i}`);
+ i++;
+ }
+ return { data: { keys } };
+ });
+}
diff --git a/ui/stories/pagination-controls.md b/ui/stories/pagination-controls.md
new file mode 100644
index 0000000000..0d59633e95
--- /dev/null
+++ b/ui/stories/pagination-controls.md
@@ -0,0 +1,26 @@
+
+
+## PaginationControls
+PaginationControls components are used to paginate through item lists
+
+**Params**
+
+| Param | Type | Default | Description |
+| --- | --- | --- | --- |
+| total | number | | total number of items |
+| [startPage] | number | 1 | initial page number to select |
+| [size] | number | 15 | number of items to display per page |
+| onChange | function | | callback fired on page change |
+
+**Example**
+
+```js
+
+```
+
+**See**
+
+- [Uses of PaginationControls](https://github.com/hashicorp/vault/search?l=Handlebars&q=PaginationControls+OR+pagination-controls)
+- [PaginationControls Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/pagination-controls.js)
+
+---
diff --git a/ui/tests/integration/components/info-table-row-test.js b/ui/tests/integration/components/info-table-row-test.js
index 55fcc71cd6..b0aded09d4 100644
--- a/ui/tests/integration/components/info-table-row-test.js
+++ b/ui/tests/integration/components/info-table-row-test.js
@@ -248,4 +248,16 @@ module('Integration | Component | InfoTableRow', function (hooks) {
assert.dom('[data-test-foo-bar]').exists();
});
+
+ test('Formats the value as date when formatDate present', async function (assert) {
+ let yearString = new Date().getFullYear().toString();
+ this.set('value', new Date());
+ await render(hbs` `);
+
+ assert.dom('[data-test-value-div]').hasText(yearString, 'Renders date with passed format');
+ });
});
diff --git a/ui/tests/integration/components/keymgmt/distribute-test.js b/ui/tests/integration/components/keymgmt/distribute-test.js
new file mode 100644
index 0000000000..0220558f8f
--- /dev/null
+++ b/ui/tests/integration/components/keymgmt/distribute-test.js
@@ -0,0 +1,152 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import Pretender from 'pretender';
+import { render, settled, select } from '@ember/test-helpers';
+import { create } from 'ember-cli-page-object';
+import { hbs } from 'ember-cli-htmlbars';
+import { typeInSearch, clickTrigger } from 'ember-power-select/test-support/helpers';
+import searchSelect from '../../../pages/components/search-select';
+
+const SELECTORS = {
+ form: '[data-test-keymgmt-distribution-form]',
+ keySection: '[data-test-keymgmt-dist-key]',
+ keyTypeSection: '[data-test-keymgmt-dist-keytype]',
+ providerInput: '[data-test-keymgmt-dist-provider]',
+ operationsSection: '[data-test-keymgmt-dist-operations]',
+ protectionsSection: '[data-test-keymgmt-dist-protections]',
+ errorKey: '[data-test-keymgmt-error="key"]',
+ errorNewKey: '[data-test-keymgmt-error="new-key"]',
+ errorProvider: '[data-test-keymgmt-error="provider"]',
+ inlineError: '[data-test-keymgmt-error]',
+};
+
+const ssComponent = create(searchSelect);
+
+module('Integration | Component | keymgmt/distribute', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.set('backend', 'keymgmt');
+ this.set('providers', ['provider-aws', 'provider-gcp', 'provider-azure']);
+ this.server = new Pretender(function () {
+ this.get('/v1/keymgmt/key', (response) => {
+ return [
+ response,
+ { 'Content-Type': 'application/json' },
+ JSON.stringify({
+ data: {
+ keys: ['example-1', 'example-2', 'example-3'],
+ },
+ }),
+ ];
+ });
+ this.get('/v1/keymgmt/key/:name', (response) => {
+ const name = response.params.name;
+ return [
+ response,
+ { 'Content-Type': 'application/json' },
+ JSON.stringify({
+ data: {
+ name,
+ type: 'aes256-gcm96', // incompatible with azurekeyvault only
+ },
+ }),
+ ];
+ });
+ this.get('/v1/keymgmt/kms/:name', (response) => {
+ const name = response.params.name;
+ let provider;
+ switch (name) {
+ case 'provider-aws':
+ provider = 'awskms';
+ break;
+ case 'provider-azure':
+ provider = 'azurekeyvault';
+ break;
+ default:
+ provider = 'gcpckms';
+ break;
+ }
+ return [
+ response,
+ { 'Content-Type': 'application/json' },
+ JSON.stringify({
+ data: {
+ name,
+ provider,
+ },
+ }),
+ ];
+ });
+ });
+ });
+
+ hooks.afterEach(function () {
+ this.server.shutdown();
+ });
+
+ test('it does not render without @backend attr', async function (assert) {
+ await render(hbs` `);
+ assert.dom(SELECTORS.form).doesNotExist('Form does not exist');
+ });
+
+ test('it does not allow operation selection until valid key and provider selected', async function (assert) {
+ await render(hbs` `);
+ assert.dom(SELECTORS.operationsSection).hasAttribute('disabled');
+ await clickTrigger();
+ await settled();
+ assert.equal(ssComponent.options.length, 3, 'shows all key options');
+ await ssComponent.selectOption();
+ await settled();
+ assert.dom(SELECTORS.operationsSection).hasAttribute('disabled');
+ await select(SELECTORS.providerInput, 'provider-aws');
+ await settled();
+ assert.dom(SELECTORS.operationsSection).doesNotHaveAttribute('disabled');
+ await select(SELECTORS.providerInput, 'provider-azure');
+ assert.dom(SELECTORS.operationsSection).hasAttribute('disabled');
+ assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error');
+ assert.dom(SELECTORS.errorProvider).exists('Shows key/provider match error on provider');
+ });
+ test('it shows key type select field if new key created', async function (assert) {
+ await render(hbs` `);
+ assert.dom(SELECTORS.keyTypeSection).doesNotExist('Key Type section is not rendered by default');
+ // Add new item on search-select
+ await clickTrigger();
+ await settled();
+ await typeInSearch('new-key');
+ await ssComponent.selectOption();
+ assert.dom(SELECTORS.keyTypeSection).exists('Key Type selector is shown');
+ });
+ test('it hides the provider field if passed from the parent', async function (assert) {
+ await render(hbs` `);
+ assert.dom(SELECTORS.providerInput).doesNotExist('Provider input is hidden');
+ // Select existing key
+ await clickTrigger();
+ await settled();
+ await ssComponent.selectOption();
+ await settled();
+ assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error');
+ assert.dom(SELECTORS.errorKey).exists('Shows error on key selector when key/provider mismatch');
+ // Remove selection
+ await ssComponent.deleteButtons.objectAt(0).click();
+ await settled();
+ // Select new key
+ await clickTrigger();
+ await settled();
+ await typeInSearch('new-key');
+ await ssComponent.selectOption();
+ await select(SELECTORS.keyTypeSection, 'ecdsa-p256');
+ assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error');
+ assert.dom(SELECTORS.errorNewKey).exists('Shows error on key type');
+ });
+ test('it hides the key field if passed from the parent', async function (assert) {
+ await render(hbs` `);
+ assert.dom(SELECTORS.providerInput).exists('Provider input shown');
+ assert.dom(SELECTORS.keySection).doesNotExist('Key input not shown');
+ await select(SELECTORS.providerInput, 'provider-azure');
+ assert.dom(SELECTORS.inlineError).exists({ count: 1 }, 'only shows single error');
+ assert.dom(SELECTORS.errorProvider).exists('Shows error due to key/provider mismatch');
+ await select(SELECTORS.providerInput, 'provider-aws');
+ assert.dom(SELECTORS.inlineError).doesNotExist('Error goes away when key/provider compatible');
+ });
+});
diff --git a/ui/tests/integration/components/keymgmt/key-edit-test.js b/ui/tests/integration/components/keymgmt/key-edit-test.js
new file mode 100644
index 0000000000..bb809bef45
--- /dev/null
+++ b/ui/tests/integration/components/keymgmt/key-edit-test.js
@@ -0,0 +1,74 @@
+import { module, test } from 'qunit';
+import EmberObject from '@ember/object';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | keymgmt/key-edit', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ const now = new Date().toString();
+ let model = EmberObject.create({
+ name: 'Unicorns',
+ id: 'Unicorns',
+ minEnabledVersion: 1,
+ versions: [
+ {
+ id: 1,
+ creation_time: now,
+ },
+ {
+ id: 2,
+ creation_time: now,
+ },
+ ],
+ });
+ this.model = model;
+ this.tab = '';
+ });
+
+ test('it renders show view as default', async function (assert) {
+ await render(hbs`
`);
+ assert.dom('[data-test-secret-header]').hasText('Unicorns', 'Shows key name');
+ assert.dom('[data-test-keymgmt-key-toolbar]').exists('Subnav toolbar exists');
+ // TODO: Add capabilities tests
+ assert.dom('[data-test-tab="Details"]').exists('Details tab exists');
+ assert.dom('[data-test-tab="Versions"]').exists('Versions tab exists');
+ assert.dom('[data-test-keymgmt-key-destroy]').isDisabled('Destroy button is disabled');
+ assert.dom('[data-test-keymgmt-dist-empty-state]').exists('Distribution empty state exists');
+
+ this.set('tab', 'versions');
+ assert.dom('[data-test-keymgmt-key-version]').exists({ count: 2 }, 'Renders two version list items');
+ assert
+ .dom('[data-test-keymgmt-key-current-min]')
+ .exists({ count: 1 }, 'Checks only one as current minimum');
+ });
+
+ test('it renders the correct elements on edit view', async function (assert) {
+ let model = EmberObject.create({
+ name: 'Unicorns',
+ id: 'Unicorns',
+ });
+ this.set('mode', 'edit');
+ this.set('model', model);
+
+ await render(hbs`
`);
+ assert.dom('[data-test-secret-header]').hasText('Edit key', 'Shows edit header');
+ assert.dom('[data-test-keymgmt-key-toolbar]').doesNotExist('Subnav toolbar does not exist');
+ assert.dom('[data-test-tab="Details"]').doesNotExist('Details tab does not exist');
+ assert.dom('[data-test-tab="Versions"]').doesNotExist('Versions tab does not exist');
+ });
+
+ test('it renders the correct elements on create view', async function (assert) {
+ let model = EmberObject.create({});
+ this.set('mode', 'create');
+ this.set('model', model);
+
+ await render(hbs`
`);
+ assert.dom('[data-test-secret-header]').hasText('Create key', 'Shows edit header');
+ assert.dom('[data-test-keymgmt-key-toolbar]').doesNotExist('Subnav toolbar does not exist');
+ assert.dom('[data-test-tab="Details"]').doesNotExist('Details tab does not exist');
+ assert.dom('[data-test-tab="Versions"]').doesNotExist('Versions tab does not exist');
+ });
+});
diff --git a/ui/tests/integration/components/keymgmt/provider-edit-test.js b/ui/tests/integration/components/keymgmt/provider-edit-test.js
new file mode 100644
index 0000000000..2a5f940410
--- /dev/null
+++ b/ui/tests/integration/components/keymgmt/provider-edit-test.js
@@ -0,0 +1,209 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { click, triggerEvent, settled, fillIn } from '@ember/test-helpers';
+import { format } from 'date-fns';
+
+const ts = 'data-test-kms-provider';
+const root = {
+ path: 'vault.cluster.secrets.backend.list-root',
+ model: 'keymgmt',
+ label: 'keymgmt',
+ text: 'keymgmt',
+};
+
+module('Integration | Component | keymgmt/provider-edit', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.store = this.owner.lookup('service:store');
+ this.store.push({
+ data: {
+ id: 'foo-bar',
+ type: 'keymgmt/provider',
+ attributes: {
+ name: 'foo-bar',
+ provider: 'azurekeyvault',
+ keyCollection: 'keyvault-1',
+ created: new Date(),
+ },
+ },
+ });
+ this.model = this.store.peekRecord('keymgmt/provider', 'foo-bar');
+ this.created = format(this.model.created, 'MMM d yyyy, h:mm:ss aaa');
+ this.root = root;
+ this.owner.lookup('service:router').reopen({
+ currentURL: '/ui/vault/secrets/keymgmt/show/foo-bar',
+ currentRouteName: 'secrets.keymgmt.provider.show',
+ urlFor() {
+ return '';
+ },
+ });
+ });
+
+ test('it should render show view', async function (assert) {
+ assert.expect(13);
+
+ this.server.get('/keymgmt/kms/foo-bar/key', () => {
+ return {
+ data: {
+ keys: ['testkey-1', 'testkey-2'],
+ },
+ };
+ });
+ this.server.delete('/keymgmt/kms/foo-bar', () => {
+ assert.ok(true, 'Request made to delete key');
+ return {};
+ });
+ this.owner.lookup('service:router').reopen({
+ transitionTo(path, model, { queryParams: { tab } }) {
+ assert.equal(path, root.path, 'Root path sent in transitionTo on delete');
+ assert.equal(model, root.model, 'Root model sent in transitionTo on delete');
+ assert.deepEqual(tab, 'provider', 'Correct query params sent in transitionTo on delete');
+ },
+ });
+
+ const changeTab = async (tab) => {
+ this.set('tab', tab);
+ await settled();
+ };
+
+ await render(hbs`
+ `);
+
+ assert.dom(`[${ts}-header]`).hasText('Provider foo-bar', 'Page header renders');
+ assert.dom(`[${ts}-tab="details"]`).hasClass('is-active', 'Details tab is active');
+
+ const infoRows = this.element.querySelectorAll('[data-test-component="info-table-row"]');
+ assert.dom(infoRows[0]).hasText('Provider name foo-bar', 'Provider name field renders');
+ assert.dom(infoRows[1]).hasText('Type Azure Key Vault', 'Type field renders');
+ assert.dom('svg', infoRows[1]).hasAttribute('data-test-icon', 'azure-color', 'Icon renders for type');
+ assert.dom(infoRows[2]).hasText(`Created ${this.created}`, 'Created field renders');
+ assert.dom(infoRows[3]).hasText('Key Vault instance name keyvault-1', 'Key collection field renders');
+ assert.dom(infoRows[4]).hasText('Keys 2 keys', 'Keys field renders');
+
+ await changeTab('keys');
+ assert.dom(`[${ts}-details-actions]`).doesNotExist('Toolbar is hidden on keys tab');
+ assert.dom('[data-test-secret-link]').exists({ count: 2 }, 'Keys list renders');
+
+ await changeTab('details');
+ assert.dom(`[${ts}-delete] button`).isDisabled('Delete action disabled when keys exist');
+ await triggerEvent(`[data-test-tooltip-trigger]`, 'mouseenter');
+ assert.dom(`[${ts}-delete-tooltip]`).exists('Tooltip is show when delete action is disabled');
+
+ this.model.keys = [];
+ await settled();
+ assert
+ .dom('[data-test-value-div="Keys"]')
+ .hasText('None', 'None is displayed when no keys exist for provider');
+ await click(`[${ts}-delete] button`);
+ await click('[data-test-confirm-button]');
+ });
+
+ test('it should render create view', async function (assert) {
+ assert.expect(10);
+
+ this.server.put('/keymgmt/kms/foo', (schema, req) => {
+ const params = {
+ name: 'foo',
+ provider: 'gcpckms',
+ key_collection: 'keyvault-1',
+ credentials: {
+ service_account_file: 'test',
+ },
+ };
+ assert.deepEqual(JSON.parse(req.requestBody), params, 'PUT request made with correct data');
+ return {};
+ });
+ this.owner.lookup('service:router').reopen({
+ transitionTo(path, model, { queryParams: { itemType } }) {
+ assert.equal(path, 'vault.cluster.secrets.backend.show', 'Show route sent in transitionTo on save');
+ assert.equal(model, 'foo', 'Model id sent in transitionTo on save');
+ assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save');
+ },
+ });
+ this.model = this.store.createRecord('keymgmt/provider');
+
+ await render(hbs`
+ `);
+
+ assert.dom(`[${ts}-header]`).hasText('Create provider', 'Page header renders');
+ assert.dom(`[${ts}-config-title]`).exists('Config header shown in create mode');
+ assert.dom(`[${ts}-creds-title]`).doesNotExist('New credentials header hidden in create mode');
+
+ await click(`[${ts}-submit]`);
+ assert
+ .dom('[data-test-inline-error-message]')
+ .exists({ count: 5 }, 'Required fields are shown on validation');
+
+ ['client_id', 'client_secret', 'tenant_id'].forEach((prop) => {
+ assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`Azure ${prop} field renders`);
+ });
+
+ await fillIn('[data-test-input="provider"]', 'awskms');
+ ['access_key', 'secret_key'].forEach((prop) => {
+ assert.dom(`[data-test-input="credentials.${prop}"]`).exists(`AWS ${prop} field renders`);
+ });
+
+ await fillIn('[data-test-input="provider"]', 'gcpckms');
+ assert.dom(`[data-test-input="credentials.service_account_file"]`).exists(`GCP cred field renders`);
+
+ await fillIn('[data-test-input="name"]', 'foo');
+ await fillIn('[data-test-input="keyCollection"]', 'keyvault-1');
+ await fillIn('[data-test-input="credentials.service_account_file"]', 'test');
+ await click(`[${ts}-submit]`);
+ });
+
+ test('it should render edit view', async function (assert) {
+ assert.expect(3);
+
+ this.server.put('/keymgmt/kms/foo', (schema, req) => {
+ const params = {
+ name: 'foo-bar',
+ provider: 'azurekeyvault',
+ key_collection: 'keyvault-1',
+ credentials: {
+ client_id: 'client_id test',
+ client_secret: 'client_secret test',
+ tenant_id: 'tenant_id test',
+ },
+ };
+ assert.deepEqual(JSON.parse(req.requestBody), params, 'PUT request made with correct data');
+ return {};
+ });
+ this.owner.lookup('service:router').reopen({
+ transitionTo(path, model, { queryParams: { itemType } }) {
+ assert.equal(path, 'vault.cluster.secrets.backend.show', 'Show route sent in transitionTo on save');
+ assert.equal(model, 'foo', 'Model id sent in transitionTo on save');
+ assert.deepEqual(itemType, 'provider', 'Correct query params sent in transitionTo on save');
+ },
+ });
+ await render(hbs`
+ `);
+
+ assert.dom(`[${ts}-header]`).hasText('Update credentials', 'Page header renders');
+ assert.dom(`[${ts}-config-title]`).doesNotExist('Config header hidden in edit mode');
+ assert.dom(`[${ts}-creds-title]`).exists('New credentials header shown in edit mode');
+
+ for (const prop of ['client_id', 'client_secret', 'tenant_id']) {
+ await fillIn(`[data-test-input="credentials.${prop}"]`, `${prop} test`);
+ }
+ await click(`[${ts}-submit]`);
+ });
+});
diff --git a/ui/tests/integration/components/pagination-controls-test.js b/ui/tests/integration/components/pagination-controls-test.js
new file mode 100644
index 0000000000..a15373edb4
--- /dev/null
+++ b/ui/tests/integration/components/pagination-controls-test.js
@@ -0,0 +1,74 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { click } from '@ember/test-helpers';
+
+module('Integration | Component | pagination-controls', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders correct number of pages', async function (assert) {
+ const totals = [
+ [10, 1],
+ [40, 3],
+ [100, 5],
+ ];
+ for (const [total, count] of totals) {
+ this.total = total;
+ await render(hbs` `);
+ assert
+ .dom('[data-test-page]')
+ .exists({ count }, `Correct page count of ${count} renders for ${total} total items`);
+ assert.dom('[data-test-more-pages]')[count === 5 ? 'exists' : 'doesNotExist']();
+ }
+ });
+
+ test('it changes pages', async function (assert) {
+ assert.expect(10);
+
+ let expectedPage = 2;
+ this.onChange = (page) => {
+ assert.equal(page, expectedPage, 'onChange callback is fired with correct page number');
+ };
+
+ await render(hbs` `);
+
+ const isActive = (page) => {
+ return this.element
+ .querySelector(`[data-test-page="${page}"]`)
+ .classList.value.includes('is-primary is-underlined is-active');
+ };
+
+ assert.ok(isActive(1), 'Page 1 is active by default');
+ assert.dom('[data-test-previous-page]').isDisabled('Previous page button is disabled on page 1');
+
+ await click('[data-test-next-page]');
+ assert.ok(isActive(2), 'Page 2 is active');
+ assert.dom('[data-test-previous-page]').isNotDisabled('Previous page button is disabled on page 1');
+
+ expectedPage = 5;
+ await click('[data-test-page="5"]');
+ assert.ok(isActive(5), 'Page 5 is active');
+ assert.dom('[data-test-next-page]').isDisabled('Next page button is disabled on last page');
+
+ expectedPage = 4;
+ await click('[data-test-previous-page]');
+ assert.ok(isActive(4), 'Page 4 is active');
+ });
+
+ test('it renders correct display info', async function (assert) {
+ this.onChange = () => {};
+ await render(hbs` `);
+
+ const ranges = ['1-15', '16-30', '31-45', '46-60', '61-68'];
+ for (const [i, range] of ranges.entries()) {
+ assert
+ .dom('[data-test-page-display-info]')
+ .hasText(`${range} of 68`, `Correct display info renders for page ${i + 1}`);
+
+ if (i < 4) {
+ await click(`[data-test-next-page]`);
+ }
+ }
+ });
+});
diff --git a/ui/tests/integration/components/search-select-test.js b/ui/tests/integration/components/search-select-test.js
index 15a6956472..1f74213976 100644
--- a/ui/tests/integration/components/search-select-test.js
+++ b/ui/tests/integration/components/search-select-test.js
@@ -297,4 +297,36 @@ module('Integration | Component | search select', function (hooks) {
let err = await promise;
assert.ok(err.message.includes('internal server error'), 'it throws an internal server error');
});
+
+ test('it returns array with objects instead of strings if passObject=true', async function (assert) {
+ const models = ['identity/entity'];
+ this.set('models', models);
+ this.set('onChange', sinon.spy());
+ this.set('passObject', true);
+ await render(hbs`{{search-select label="foo" models=models onChange=onChange passObject=passObject}}`);
+
+ await clickTrigger();
+ await settled();
+ // First select existing option
+ await component.selectOption();
+ assert.equal(component.selectedOptions.length, 1, 'there is 1 selected option');
+ assert.ok(this.onChange.calledOnce);
+ assert.ok(
+ this.onChange.calledWith([{ id: '7', isNew: false }]),
+ 'onClick is called with array of single object with isNew false'
+ );
+ // Then create a new item and select it
+ await clickTrigger();
+ await settled();
+ await typeInSearch('newItem');
+ await component.selectOption();
+ await settled();
+ assert.ok(
+ this.onChange.calledWith([
+ { id: '7', isNew: false },
+ { id: 'newItem', isNew: true },
+ ]),
+ 'onClick is called with array of objects with isNew true on new item'
+ );
+ });
});