mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 09:42:25 +00:00
Key Management Secrets Engine Phase 1 (#15036)
* KMSE: Key Model / Adapter / Serializer setup (#13638) * First pass model * KMS key adapter (create/update), serializer, model * Add last rotated and provider to key * KeyEdit secret-edit component, and more key model stuff * add formatDate param support to infotablerow * Add keymgmt key to routes and options-for-backend * Rename keymgmt-key to keymgmt/key * Add test, cleanup * Add mirage handler for kms * Address PR comments * KMS Providers (#13797) * adds pagination-controls component * adds kms provider model, adapter and serializer * adds kms provider-edit component * updates secrets routes to handle itemType query param for kms * updates kms key adapter to query by provider * adds tests for provider-edit component * refactors kms provider adapter to account for dynamic path * adds model-validations-helper util * removes keymgmt from supported-secret-backends * fixes issue generating url for fetching keys for a provider * updates modelType method on secret-edit route to accept options object as arg rather than transition * adds additional checks to ensure queryParams are defined in options object for modelType method * UI/keymgmt distribute key (#13840) * Add distribution details on key page, and empty states if no permissions * Allow search-select component to return object so parent can tell when new item was created * Add stringarray transform * Distribute component first pass * Refactor distribute component for use with internal object rather than ember-data model * Specific permission denied errors on key edit * Allow inline errors on search-select component * Style updates for form errors * Styling and error messages on distribute component * Allow block template on inline alert so we can add doc links * Add distribute action, flash messages, cleanup * Cleanup & Add tests * More cleanup * Address PR comments * Move disable operations logic to commponent class * KMSE Enable/Config (#14835) * adds keymgmt secrets engine as supported backend * adds comment to check on keymgmt as member of adp module * updates kms provider to use model-validations decorator * fixes lint errors and tests Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>
This commit is contained in:
152
ui/app/adapters/keymgmt/key.js
Normal file
152
ui/app/adapters/keymgmt/key.js
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
63
ui/app/adapters/keymgmt/provider.js
Normal file
63
ui/app/adapters/keymgmt/provider.js
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
247
ui/app/components/keymgmt/distribute.js
Normal file
247
ui/app/components/keymgmt/distribute.js
Normal file
@@ -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
|
||||
* <KeymgmtDistribute @backend="keymgmt" @key="my-key" @provider="my-kms" />
|
||||
* ```
|
||||
* @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));
|
||||
}
|
||||
}
|
||||
98
ui/app/components/keymgmt/key-edit.js
Normal file
98
ui/app/components/keymgmt/key-edit.js
Normal file
@@ -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
|
||||
* <KeymgmtKeyEdit @model={model} @mode="show" @tab="versions" />
|
||||
* ```
|
||||
* @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);
|
||||
});
|
||||
}
|
||||
}
|
||||
97
ui/app/components/keymgmt/provider-edit.js
Normal file
97
ui/app/components/keymgmt/provider-edit.js
Normal file
@@ -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
|
||||
* <KeymgmtProviderEdit @model={model} @mode="show" />
|
||||
* ```
|
||||
* @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('. '));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
57
ui/app/components/pagination-controls.js
Normal file
57
ui/app/components/pagination-controls.js
Normal file
@@ -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
|
||||
* <PaginationControls @startPage={{1}} @total={{100}} @size={{15}} @onChange={{this.onPageChange}} />
|
||||
* ```
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
5
ui/app/helpers/sub.js
Normal file
5
ui/app/helpers/sub.js
Normal file
@@ -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);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const SUPPORTED_SECRET_BACKENDS = [
|
||||
'transit',
|
||||
'kmip',
|
||||
'transform',
|
||||
'keymgmt',
|
||||
];
|
||||
|
||||
export function supportedSecretBackends() {
|
||||
|
||||
96
ui/app/models/keymgmt/key.js
Normal file
96
ui/app/models/keymgmt/key.js
Normal file
@@ -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.' },
|
||||
];
|
||||
}
|
||||
}
|
||||
121
ui/app/models/keymgmt/provider.js
Normal file
121
ui/app/models/keymgmt/provider.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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';
|
||||
|
||||
34
ui/app/serializers/keymgmt/key.js
Normal file
34
ui/app/serializers/keymgmt/key.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
13
ui/app/serializers/keymgmt/provider.js
Normal file
13
ui/app/serializers/keymgmt/provider.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,12 @@
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-link-subtle {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
font-weight: inherit;
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -326,7 +326,8 @@ fieldset.form-fieldset {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.has-error-border {
|
||||
.has-error-border,
|
||||
select.has-error-border {
|
||||
border: 1px solid $red-500;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
color: $black;
|
||||
text-decoration: none;
|
||||
}
|
||||
.has-font-weight-normal {
|
||||
font-weight: $font-weight-normal;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section .title {
|
||||
|
||||
155
ui/app/templates/components/keymgmt/distribute.hbs
Normal file
155
ui/app/templates/components/keymgmt/distribute.hbs
Normal file
@@ -0,0 +1,155 @@
|
||||
{{#if @backend}}
|
||||
<form {{on "submit" this.createDistribution}} class="form-section" data-test-keymgmt-distribution-form>
|
||||
{{#unless @key}}
|
||||
<div class="field" data-test-keymgmt-dist-key>
|
||||
<SearchSelect
|
||||
@id="key"
|
||||
@models={{array "keymgmt/key"}}
|
||||
@onChange={{this.handleKeySelect}}
|
||||
@passObject={{true}}
|
||||
@inputValue={{this.formData.key}}
|
||||
@subText="Type to use the name of an existing key that you’d like to add to this provider, or to create one."
|
||||
@wildcardLabel="key"
|
||||
@label=""
|
||||
@subLabel="Key name"
|
||||
@fallbackComponent="string-list"
|
||||
@selectLimit="1"
|
||||
@backend={{@backend}}
|
||||
@disallowNewItems={{false}}
|
||||
>
|
||||
{{#if (and this.validMatchError.key (not this.isNewKey))}}
|
||||
<AlertInline @paddingTop={{true}} @sizeSmall={{true}} @type="danger" data-test-keymgmt-error="key">
|
||||
{{this.validMatchError.key}}
|
||||
To check compatibility,
|
||||
<DocLink class="doc-link-subtle" @path="/docs/secrets/key-management#compatibility">refer to this table</DocLink>.
|
||||
</AlertInline>
|
||||
{{/if}}
|
||||
</SearchSelect>
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
{{#if this.isNewKey}}
|
||||
<div class="field">
|
||||
<label class="is-label" for="keyType">Key Type</label>
|
||||
<p class="sub-text">The type of cryptographic key that will be created.</p>
|
||||
<div class="control is-expanded">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
name="keyType"
|
||||
id="keyType"
|
||||
{{on "change" this.handleKeyType}}
|
||||
class={{if this.validMatchError.key "has-error-border"}}
|
||||
data-test-keymgmt-dist-keytype
|
||||
>
|
||||
<option value="">
|
||||
Select one
|
||||
</option>
|
||||
{{#each this.keyTypes as |val|}}
|
||||
<option selected={{eq this.keyType val}} value={{val}}>
|
||||
{{val}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
{{#if this.validMatchError.key}}
|
||||
<AlertInline @paddingTop={{true}} @sizeSmall={{true}} @type="danger" data-test-keymgmt-error="new-key">
|
||||
{{this.validMatchError.key}}
|
||||
To check compatibility,
|
||||
<DocLink class="doc-link-subtle" @path="/docs/secrets/key-management#compatibility">refer to this table</DocLink>.
|
||||
</AlertInline>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#unless @provider}}
|
||||
<div class="field">
|
||||
<label class="is-label" for="provider">Provider</label>
|
||||
<p class="sub-text">Select a provider in Vault. If it doesn’t exist yet, you’ll need to add it first.</p>
|
||||
<div class="control is-expanded">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
name="provider"
|
||||
id="provider"
|
||||
{{on "change" this.handleProvider}}
|
||||
class={{if this.validMatchError.provider "has-error-border"}}
|
||||
data-test-keymgmt-dist-provider
|
||||
>
|
||||
<option value="">
|
||||
Select provider
|
||||
</option>
|
||||
{{#each @providers as |val|}}
|
||||
<option selected={{eq @model.provider val}} value={{val}}>
|
||||
{{val}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.validMatchError.provider}}
|
||||
<AlertInline @paddingTop={{true}} @sizeSmall={{true}} @type="danger" data-test-keymgmt-error="provider">
|
||||
{{this.validMatchError.provider}}
|
||||
To check compatibility,
|
||||
<DocLink class="doc-link-subtle" @path="/docs/secrets/key-management#compatibility">refer to this table</DocLink>.
|
||||
</AlertInline>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
|
||||
<fieldset
|
||||
class="field form-fieldset"
|
||||
id="operations"
|
||||
disabled={{this.disableOperations}}
|
||||
data-test-keymgmt-dist-operations
|
||||
>
|
||||
<legend class="is-label">Operations</legend>
|
||||
<p class="sub-text">The types of operations this key can perform in the provider.</p>
|
||||
{{#each this.operations as |op|}}
|
||||
<div class="b-checkbox">
|
||||
<Input @type="checkbox" id={{op}} class="styled" @checked={{false}} {{on "input" this.handleOperation}} />
|
||||
<label for={{op}}>{{capitalize op}}</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="field form-fieldset" id="protection" data-test-keymgmt-dist-protections>
|
||||
<legend class="is-label">Protection</legend>
|
||||
<p class="sub-text">Specifies the protection of the key.</p>
|
||||
<div>
|
||||
<RadioButton
|
||||
id="protection-hsm"
|
||||
name="hsm"
|
||||
class="radio"
|
||||
@value="hsm"
|
||||
@groupValue={{this.formData.protection}}
|
||||
@onChange={{fn (mut this.formData.protection)}}
|
||||
/>
|
||||
<label for="protection-hsm">HSM</label>
|
||||
</div>
|
||||
<div>
|
||||
<RadioButton
|
||||
id="protection-software"
|
||||
name="software"
|
||||
class="radio"
|
||||
@value="software"
|
||||
@groupValue={{this.formData.protection}}
|
||||
@onChange={{fn (mut this.formData.protection)}}
|
||||
/>
|
||||
<label for="protection-software">Software</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={{or this.validationErrorCount this.error}}
|
||||
class="button is-primary"
|
||||
data-test-secret-save={{true}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
||||
215
ui/app/templates/components/keymgmt/key-edit.hbs
Normal file
215
ui/app/templates/components/keymgmt/key-edit.hbs
Normal file
@@ -0,0 +1,215 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{this.mode}} @root={{@root}} @showCurrent={{true}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-secret-header="true">
|
||||
{{#if (eq @mode "create")}}
|
||||
Create key
|
||||
{{else if (eq @mode "edit")}}
|
||||
Edit key
|
||||
{{else}}
|
||||
{{@model.id}}
|
||||
{{/if}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if (eq this.mode "show")}}
|
||||
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless" data-test-keymgmt-key-toolbar>
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
<li class={{if (not-eq @tab "versions") "is-active"}}>
|
||||
<LinkTo
|
||||
@route="vault.cluster.secrets.backend.show"
|
||||
@model={{@model.id}}
|
||||
@query={{hash tab=""}}
|
||||
data-test-tab="Details"
|
||||
>
|
||||
Details
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li class={{if (eq @tab "versions") "is-active"}}>
|
||||
<LinkTo
|
||||
@route="vault.cluster.secrets.backend.show"
|
||||
@model={{@model.id}}
|
||||
@query={{hash tab="versions"}}
|
||||
data-test-tab="Versions"
|
||||
>
|
||||
Versions
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<Toolbar>
|
||||
<ToolbarActions>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-link"
|
||||
disabled={{not @model.deletionAllowed}}
|
||||
{{on "click" (fn (mut this.isDeleteModalOpen) true)}}
|
||||
data-test-keymgmt-key-destroy
|
||||
>
|
||||
Destroy key
|
||||
</button>
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@onConfirmAction={{fn this.removeKey @model.id}}
|
||||
@confirmTitle="Remove this key?"
|
||||
@confirmMessage="This will remove all versions of the key from the KMS provider. The key will stay in Vault."
|
||||
@confirmButtonText="Remove"
|
||||
data-test-keymgmt-key-remove
|
||||
>
|
||||
Remove key
|
||||
</ConfirmAction>
|
||||
<div class="toolbar-separator"></div>
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@onConfirmAction={{fn this.rotateKey @model.id}}
|
||||
@confirmTitle="Rotate this key?"
|
||||
@confirmMessage="After rotation, all key actions will default to using the newest version of the key."
|
||||
@confirmButtonText="Rotate"
|
||||
data-test-keymgmt-key-rotate
|
||||
>
|
||||
Rotate key
|
||||
</ConfirmAction>
|
||||
<ToolbarSecretLink
|
||||
@secret={{@model.id}}
|
||||
@mode="edit"
|
||||
@replace={{true}}
|
||||
@queryParams={{query-params itemType="key"}}
|
||||
@data-test-edit-link={{true}}
|
||||
>
|
||||
Edit key
|
||||
</ToolbarSecretLink>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if (eq this.mode "create")}}
|
||||
<form {{on "submit" this.createKey}}>
|
||||
{{#each @model.createFields as |attr|}}
|
||||
<FormField @attr={{attr}} @model={{@model}} />
|
||||
{{/each}}
|
||||
<input type="submit" value="Create key" />
|
||||
</form>
|
||||
{{else if (eq this.mode "edit")}}
|
||||
<form {{on "submit" this.updateKey}}>
|
||||
{{#each @model.updateFields as |attr|}}
|
||||
<FormField data-test-field={{true}} @attr={{attr}} @model={{@model}} />
|
||||
{{/each}}
|
||||
<input type="submit" value="Update" />
|
||||
</form>
|
||||
{{else if (eq @tab "versions")}}
|
||||
{{#each @model.versions as |version|}}
|
||||
<div class="list-item-row" data-test-keymgmt-key-version>
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-3 has-text-weight-bold">
|
||||
<Icon @name="history" class="has-text-grey-light" />
|
||||
<span>Version {{version.id}}</span>
|
||||
</div>
|
||||
<div class="column is-3 has-text-grey">
|
||||
{{date-from-now version.creation_time addSuffix=true}}
|
||||
</div>
|
||||
<div class="column is-6 is-flex-center">
|
||||
{{#if (eq @model.minEnabledVersion version.id)}}
|
||||
<Icon @name="check-circle-fill" class="has-text-success" />
|
||||
<span data-test-keymgmt-key-current-min>Current mininum enabled version</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="has-top-margin-xl has-bottom-margin-s">
|
||||
<h2 class="title has-border-bottom-light is-5">Key Details</h2>
|
||||
{{#each @model.showFields as |attr|}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get @model attr.name}}
|
||||
@defaultShown={{attr.options.defaultShown}}
|
||||
@formatDate={{if (eq attr.type "date") "MMM d yyyy, h:mm:ss aaa"}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="has-top-margin-xl has-bottom-margin-s">
|
||||
<h2 class="title has-border-bottom-light is-5 {{if @model.provider.permissionsError 'is-borderless is-marginless'}}">
|
||||
Distribution Details
|
||||
</h2>
|
||||
{{! TODO: Use capabilities to tell if it's not distributed vs no permissions }}
|
||||
{{#if @model.provider.permissionsError}}
|
||||
<EmptyState
|
||||
@title="You are not authorized"
|
||||
@subTitle="Error 403"
|
||||
@message={{concat
|
||||
"You must be granted permissions to see whether this key is distributed. Ask your administrator if you think you should have access to LIST /"
|
||||
@model.backend
|
||||
"/key/"
|
||||
@model.name
|
||||
"/kms."
|
||||
}}
|
||||
@icon="minus-circle"
|
||||
/>
|
||||
{{else if @model.provider}}
|
||||
<InfoTableRow @label="Distributed" @value={{@model.provider}}>
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{concat "kms/" @model.provider}}>
|
||||
<Icon @name="check-circle-fill" class="has-text-success" />{{@model.provider}}
|
||||
</LinkTo>
|
||||
</InfoTableRow>
|
||||
{{#if @model.distribution}}
|
||||
{{#each @model.distFields as |attr|}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@label={{capitalize (or attr.label (humanize (dasherize attr.name)))}}
|
||||
@value={{if
|
||||
(eq attr.name "protection")
|
||||
(uppercase (get @model.distribution attr.name))
|
||||
(get @model.distribution attr.name)
|
||||
}}
|
||||
@defaultShown={{attr.defaultShown}}
|
||||
@helperText={{attr.subText}}
|
||||
@formatDate={{if (eq attr.type "date") "MMM d yyyy, h:mm:ss aaa"}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="You are not authorized"
|
||||
@subTitle="Error 403"
|
||||
@message="You must be granted permissions to view distribution details for this key. Ask your administrator if you think you should have access to GET /keymgmt/keymgmt/key/example."
|
||||
@icon="minus-circle"
|
||||
/>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="Key not distributed"
|
||||
@message="When this key is distributed to a destination, those details will appear here."
|
||||
data-test-keymgmt-dist-empty-state
|
||||
>
|
||||
{{! TODO: Distribute link
|
||||
<LinkTo @route="vault.cluster.secrets.backend.distribute">
|
||||
Distribute
|
||||
</LinkTo> }}
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<ConfirmationModal
|
||||
@title="Destroy key?"
|
||||
@onClose={{fn (mut this.isDeleteModalOpen) false}}
|
||||
@isActive={{this.isDeleteModalOpen}}
|
||||
@confirmText={{@model.name}}
|
||||
@toConfirmMsg="deleting the key"
|
||||
@onConfirm={{fn this.deleteKey @model.id}}
|
||||
@testSelector="delete"
|
||||
>
|
||||
<p>
|
||||
Destroying the
|
||||
<strong>{{@model.name}}</strong>
|
||||
key means that the underlying data will be lost and the key will become unusable for cryptographic operations. It is
|
||||
unrecoverable.
|
||||
</p>
|
||||
<MessageError @model={{this.model}} @errorMessage={{this.error}} />
|
||||
</ConfirmationModal>
|
||||
190
ui/app/templates/components/keymgmt/provider-edit.hbs
Normal file
190
ui/app/templates/components/keymgmt/provider-edit.hbs
Normal file
@@ -0,0 +1,190 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<KeyValueHeader @path="vault.cluster.secrets.backend.show" @mode={{@mode}} @root={{@root}} @showCurrent={{true}} />
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3" data-test-kms-provider-header>
|
||||
{{#if this.isShowing}}
|
||||
Provider
|
||||
<span class="has-font-weight-normal">{{@model.id}}</span>
|
||||
{{else}}
|
||||
{{if this.isCreating "Create provider" "Update credentials"}}
|
||||
{{/if}}
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
{{#if this.isShowing}}
|
||||
<div class="tabs-container box is-sideless is-fullwidth is-paddingless is-marginless">
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
<li class={{unless this.viewingKeys "is-active"}} data-test-kms-provider-tab="details">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab=""}}>
|
||||
Details
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li class={{if this.viewingKeys "is-active"}} data-test-kms-provider-tab="keys">
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}>
|
||||
Keys
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{#unless this.viewingKeys}}
|
||||
<Toolbar data-test-kms-provider-details-actions>
|
||||
<ToolbarActions>
|
||||
<ToolTip @verticalPosition="above" @horizontalPosition="center" as |T|>
|
||||
<T.Trigger data-test-tooltip-trigger>
|
||||
<ConfirmAction
|
||||
@buttonClasses="toolbar-link"
|
||||
@onConfirmAction={{this.onDelete}}
|
||||
@disabled={{@model.keys.length}}
|
||||
data-test-kms-provider-delete={{true}}
|
||||
>
|
||||
Delete provider
|
||||
</ConfirmAction>
|
||||
</T.Trigger>
|
||||
{{#if @model.keys.length}}
|
||||
<T.Content class="tool-tip">
|
||||
<div class="box" data-test-kms-provider-delete-tooltip>
|
||||
This provider cannot be deleted until all 20 keys distributed to it are revoked. This can be done from the
|
||||
Keys tab.
|
||||
</div>
|
||||
</T.Content>
|
||||
{{/if}}
|
||||
</ToolTip>
|
||||
<div class="toolbar-separator"></div>
|
||||
{{! Update once distribute route has been created }}
|
||||
{{! <LinkTo @route="vault.cluster.secrets.backend.kms-distribute">
|
||||
Distribute key
|
||||
<Icon @name="chevron-right" />
|
||||
</LinkTo> }}
|
||||
<ToolbarSecretLink
|
||||
@secret={{@model.id}}
|
||||
@mode="edit"
|
||||
@replace={{true}}
|
||||
@queryParams={{query-params itemType="provider"}}
|
||||
>
|
||||
Update credentials
|
||||
</ToolbarSecretLink>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
{{/unless}}
|
||||
{{else}}
|
||||
<form aria-label="update credentials" {{on "submit" this.onSave}}>
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
{{#if this.isCreating}}
|
||||
{{#each @model.createFields as |attr index|}}
|
||||
{{#if (eq index 2)}}
|
||||
<div class="has-border-top-light">
|
||||
<h2 class="title is-5 has-top-margin-l has-bottom-margin-m" data-test-kms-provider-config-title>
|
||||
Provider configuration
|
||||
</h2>
|
||||
</div>
|
||||
{{/if}}
|
||||
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#unless this.isCreating}}
|
||||
<h2 class="title is-5" data-test-kms-provider-creds-title>
|
||||
New credentials
|
||||
</h2>
|
||||
<p class="sub-text has-bottom-margin-m">
|
||||
Old credentials cannot be read and will be lost as soon as new ones are added. Do this carefully.
|
||||
</p>
|
||||
{{/unless}}
|
||||
{{#each @model.credentialFields as |cred|}}
|
||||
<FormField @attr={{cred}} @model={{@model}} @modelValidations={{this.modelValidations}} />
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={{this.saveTask.isRunning}}
|
||||
class="button is-primary {{if this.saveTask.isRunning 'is-loading'}}"
|
||||
data-test-kms-provider-submit
|
||||
>
|
||||
{{if this.isCreating "Create provider" "Update"}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<LinkTo
|
||||
@route={{if this.isCreating @root.path "vault.cluster.secrets.backend.show"}}
|
||||
@model={{if this.isCreating @root.model @model.id}}
|
||||
@query={{if this.isCreating (hash tab="provider") (hash itemType="provider")}}
|
||||
@disabled={{this.saveTask.isRunning}}
|
||||
class="button"
|
||||
data-test-kms-provider-cancel
|
||||
>
|
||||
Cancel
|
||||
</LinkTo>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isShowing}}
|
||||
<div class="has-bottom-margin-s">
|
||||
{{#if this.viewingKeys}}
|
||||
{{#let (options-for-backend "keymgmt" "key") as |options|}}
|
||||
{{#if @model.keys.meta.total}}
|
||||
{{#each @model.keys as |key|}}
|
||||
<SecretList::Item
|
||||
@item={{key}}
|
||||
@backendModel={{@root}}
|
||||
@backendType="keymgmt"
|
||||
@delete={{fn this.onDeleteKey key}}
|
||||
@itemPath={{concat options.modelPrefix key.id}}
|
||||
@itemType={{options.item}}
|
||||
@modelType={{@modelType}}
|
||||
@options={{options}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{#if (gt @model.keys.meta.lastPage 1)}}
|
||||
<PaginationControls
|
||||
@total={{@model.keys.meta.total}}
|
||||
@onChange={{perform this.fetchKeys}}
|
||||
class="has-top-margin-xl has-bottom-margin-l"
|
||||
/>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="No keys for this provider"
|
||||
@message="Keys for this provider will be listed here. Add a key to get started."
|
||||
>
|
||||
<SecretLink @mode="create" @secret="" @queryParams={{query-params itemType="key"}} @class="link">
|
||||
Create key
|
||||
</SecretLink>
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{else}}
|
||||
{{#each @model.showFields as |attr|}}
|
||||
{{#if attr.hasBlock}}
|
||||
<InfoTableRow @label={{attr.label}} @value={{attr.value}} data-test-kms-provider-field={{attr.name}}>
|
||||
{{#if attr.icon}}
|
||||
<Icon @name={{attr.icon}} class="icon" />
|
||||
{{/if}}
|
||||
{{#if attr.isLink}}
|
||||
<LinkTo @route="vault.cluster.secrets.backend.show" @model={{@model.id}} @query={{hash tab="keys"}}>
|
||||
{{attr.value}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
{{attr.value}}
|
||||
{{/if}}
|
||||
</InfoTableRow>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get @model attr.name}}
|
||||
@defaultShown={{attr.options.defaultShown}}
|
||||
@formatDate={{if (eq attr.type "date") "MMM d yyyy, h:mm:ss aaa"}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -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."
|
||||
|
||||
44
ui/app/templates/components/pagination-controls.hbs
Normal file
44
ui/app/templates/components/pagination-controls.hbs
Normal file
@@ -0,0 +1,44 @@
|
||||
<div class="is-flex-between is-flex-center" ...attributes>
|
||||
<div class="is-fullwidth is-flex-center">
|
||||
<p class="has-text-grey has-left-margin-l" data-test-page-display-info>
|
||||
{{this.displayInfo}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="is-fullwidth is-flex-v-centered">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-flat has-short-padding"
|
||||
disabled={{eq this.page 1}}
|
||||
data-test-previous-page
|
||||
{{on "click" (fn this.changePage (sub this.page 1))}}
|
||||
>
|
||||
<Icon @name="chevron-left" />
|
||||
Previous
|
||||
</button>
|
||||
{{#each this.pages as |page|}}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-flat has-left-margin-xxs {{if (eq this.page page) 'is-primary is-underlined is-active'}}"
|
||||
data-test-page={{page}}
|
||||
{{on "click" (fn this.changePage page)}}
|
||||
>
|
||||
{{page}}
|
||||
</button>
|
||||
{{/each}}
|
||||
{{#if this.hasMorePages}}
|
||||
<span class="has-text-grey has-left-margin-m" data-test-more-pages>...</span>
|
||||
{{/if}}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-flat has-short-padding has-left-margin-l"
|
||||
disabled={{eq this.page this.totalPages}}
|
||||
data-test-next-page
|
||||
{{on "click" (fn this.changePage (add this.page 1))}}
|
||||
>
|
||||
Next
|
||||
<Icon @name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
{{! intentionally empty to place buttons in the middle }}
|
||||
<div class="is-fullwidth"></div>
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
<Icon @name={{or @model.engineType "secrets"}} @size="24" class="has-text-grey-light" />
|
||||
<Icon @name={{@model.icon}} @size="24" class="has-text-grey-light" />
|
||||
{{@model.id}}
|
||||
{{#if this.isKV}}
|
||||
<span class="tag" data-test-kv-version-badge>
|
||||
|
||||
@@ -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}}
|
||||
>
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-10">
|
||||
<SecretLink
|
||||
@mode={{if @item.isFolder "list" "show"}}
|
||||
@secret={{@item.id}}
|
||||
@queryParams={{if (eq @backendModel.type "transit") (query-params tab="actions") ""}}
|
||||
@queryParams={{secret-query-params @backendModel.type @item.type asQueryParams=true}}
|
||||
class="has-text-black has-text-weight-semibold"
|
||||
>
|
||||
{{#if (eq @backendModel.type "transit")}}
|
||||
<Icon @name="key" class="has-text-grey-light" />
|
||||
{{else}}
|
||||
<Icon @name={{if @item.isFolder "folder" "file"}} class="has-text-grey-light" />
|
||||
<Icon @name={{if @item.isFolder "folder" (or @item.icon "file")}} class="has-text-grey-light" />
|
||||
{{/if}}
|
||||
{{if (eq @item.id " ") "(self)" (or @item.keyWithoutParent @item.id)}}
|
||||
</SecretLink>
|
||||
|
||||
@@ -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}}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
{{else}}
|
||||
<Icon @name="minus" />
|
||||
{{/if}}
|
||||
{{else if @formatDate}}
|
||||
{{date-format @value @formatDate}}
|
||||
{{else}}
|
||||
{{#if (eq @type "array")}}
|
||||
<InfoTableItemArray
|
||||
|
||||
@@ -21,6 +21,7 @@ import layout from '../templates/components/search-select';
|
||||
* @param {string} fallbackComponent - name of component to be rendered if the API call 403s
|
||||
* @param {string} [backend] - name of the backend if the query for options needs additional information (eg. secret backend)
|
||||
* @param {boolean} [disallowNewItems=false] - Controls whether or not the user can add a new item if none found
|
||||
* @param {boolean} [passObject=false] - When true, the onChange callback returns an array of objects with id (string) and isNew (boolean)
|
||||
* @param {string} [helpText] - Text to be displayed in the info tooltip for this form field
|
||||
* @param {number} [selectLimit] - A number that sets the limit to how many select options they can choose
|
||||
* @param {string} [subText] - Text to be displayed below the label
|
||||
@@ -54,6 +55,7 @@ export default Component.extend({
|
||||
shouldUseFallback: false,
|
||||
shouldRenderName: false,
|
||||
disallowNewItems: false,
|
||||
passObject: false,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
@@ -133,7 +135,11 @@ export default Component.extend({
|
||||
}).on('didInsertElement'),
|
||||
handleChange() {
|
||||
if (this.selectedOptions.length && typeof this.selectedOptions.firstObject === 'object') {
|
||||
this.onChange(Array.from(this.selectedOptions, (option) => 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);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const POSSIBLE_FEATURES = [
|
||||
'Namespaces',
|
||||
'KMIP',
|
||||
'Transform Secrets Engine',
|
||||
'Key Management Secrets Engine',
|
||||
];
|
||||
|
||||
export function hasFeature(featureName, features) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<Icon @name={{this.alertType.glyph}} class={{this.alertType.glyphClass}} />
|
||||
<p class={{this.textClass}} data-test-inline-error-message>
|
||||
{{@message}}
|
||||
{{#if (has-block)}}
|
||||
{{yield}}
|
||||
{{else}}
|
||||
{{@message}}
|
||||
{{/if}}
|
||||
</p>
|
||||
@@ -80,4 +80,7 @@
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if (has-block)}}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -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 };
|
||||
|
||||
58
ui/mirage/handlers/kms.js
Normal file
58
ui/mirage/handlers/kms.js
Normal file
@@ -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 } };
|
||||
});
|
||||
}
|
||||
26
ui/stories/pagination-controls.md
Normal file
26
ui/stories/pagination-controls.md
Normal file
@@ -0,0 +1,26 @@
|
||||
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/pagination-controls.js. To make changes, first edit that file and run "yarn gen-story-md pagination-controls" to re-generate the content.-->
|
||||
|
||||
## PaginationControls
|
||||
PaginationControls components are used to paginate through item lists
|
||||
|
||||
**Params**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| total | <code>number</code> | | total number of items |
|
||||
| [startPage] | <code>number</code> | <code>1</code> | initial page number to select |
|
||||
| [size] | <code>number</code> | <code>15</code> | number of items to display per page |
|
||||
| onChange | <code>function</code> | | callback fired on page change |
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
<PaginationControls @startPage={{1}} @total={{100}} @size={{15}} @onChange={{this.onPageChange}} />
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
---
|
||||
@@ -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`<InfoTableRow
|
||||
@label={{this.label}}
|
||||
@value={{this.value}}
|
||||
@formatDate={{'yyyy'}}
|
||||
/>`);
|
||||
|
||||
assert.dom('[data-test-value-div]').hasText(yearString, 'Renders date with passed format');
|
||||
});
|
||||
});
|
||||
|
||||
152
ui/tests/integration/components/keymgmt/distribute-test.js
Normal file
152
ui/tests/integration/components/keymgmt/distribute-test.js
Normal file
@@ -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`<Keymgmt::Distribute />`);
|
||||
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`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} />`);
|
||||
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`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} />`);
|
||||
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`<Keymgmt::Distribute @backend="keymgmt" @provider="provider-azure" />`);
|
||||
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`<Keymgmt::Distribute @backend="keymgmt" @providers={{providers}} @key="example-1" />`);
|
||||
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');
|
||||
});
|
||||
});
|
||||
74
ui/tests/integration/components/keymgmt/key-edit-test.js
Normal file
74
ui/tests/integration/components/keymgmt/key-edit-test.js
Normal file
@@ -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`<Keymgmt::KeyEdit @model={{model}} @tab={{tab}} /><div id="modal-wormhole" />`);
|
||||
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`<Keymgmt::KeyEdit @model={{model}} @mode={{mode}} /><div id="modal-wormhole" />`);
|
||||
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`<Keymgmt::KeyEdit @model={{model}} @mode={{mode}} /><div id="modal-wormhole" />`);
|
||||
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');
|
||||
});
|
||||
});
|
||||
209
ui/tests/integration/components/keymgmt/provider-edit-test.js
Normal file
209
ui/tests/integration/components/keymgmt/provider-edit-test.js
Normal file
@@ -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`
|
||||
<Keymgmt::ProviderEdit
|
||||
@root={{this.root}}
|
||||
@model={{this.model}}
|
||||
@mode="show"
|
||||
@tab={{this.tab}}
|
||||
/>`);
|
||||
|
||||
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`
|
||||
<Keymgmt::ProviderEdit
|
||||
@root={{this.root}}
|
||||
@model={{this.model}}
|
||||
@mode="create"
|
||||
/>`);
|
||||
|
||||
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`
|
||||
<Keymgmt::ProviderEdit
|
||||
@root={{this.root}}
|
||||
@model={{this.model}}
|
||||
@mode="edit"
|
||||
/>`);
|
||||
|
||||
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]`);
|
||||
});
|
||||
});
|
||||
74
ui/tests/integration/components/pagination-controls-test.js
Normal file
74
ui/tests/integration/components/pagination-controls-test.js
Normal file
@@ -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`<PaginationControls @total={{this.total}} />`);
|
||||
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`<PaginationControls @total={{75}} @onChange={{this.onChange}} />`);
|
||||
|
||||
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`<PaginationControls @total={{68}} @onChange={{this.onChange}} />`);
|
||||
|
||||
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]`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user