Skip to content

Commit

Permalink
UI: [VAULT-17035 VAULT-17038 VAULT-17039] Dashboard Quick Actions card (
Browse files Browse the repository at this point in the history
  • Loading branch information
kiannaquach and hellobontempo authored Aug 8, 2023
1 parent cf558ab commit a9603f6
Show file tree
Hide file tree
Showing 5 changed files with 343 additions and 1 deletion.
146 changes: 146 additions & 0 deletions ui/app/components/dashboard/quick-actions-card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';

/**
* @module DashboardQuickActionsCard
* DashboardQuickActionsCard component allows users to see a list of secrets engines filtered by
* kv, pki and database and perform certain actions based on the type of secret engine selected
*
* @example
* ```js
* <Dashboard::QuickActionsCard @secretsEngines={{@model.secretsEngines}} />
* ```
*/

const QUICK_ACTION_ENGINES = ['pki', 'kv', 'database'];

export default class DashboardQuickActionsCard extends Component {
@service router;

@tracked selectedEngine;
@tracked selectedAction;
@tracked mountPath;
@tracked paramValue;

get actionOptions() {
switch (this.selectedEngine) {
case 'kv':
return ['Find KV secrets'];
case 'database':
return ['Generate credentials for database'];
case 'pki':
return ['Issue certificate', 'View certificate', 'View issuer'];
default:
return [];
}
}

get searchSelectParams() {
switch (this.selectedAction) {
case 'Find KV secrets':
return {
title: 'Secret Path',
subText: 'Path of the secret you want to read, including the mount. E.g., secret/data/foo.',
buttonText: 'Read secrets',
model: 'secret-v2',
route: 'vault.cluster.secrets.backends.show',
};
case 'Generate credentials for database':
return {
title: 'Role to use',
buttonText: 'Generate credentials',
model: 'database/role',
route: 'vault.cluster.secrets.backend.credentials',
};
case 'Issue certificate':
return {
title: 'Role to use',
placeholder: 'Type to find a role',
buttonText: 'Issue leaf certificate',
model: 'pki/role',
route: 'vault.cluster.secrets.backend.pki.roles.role.generate',
};
case 'View certificate':
return {
title: 'Certificate serial number',
placeholder: '33:a3:...',
buttonText: 'View certificate',
model: 'pki/certificate/base',
route: 'vault.cluster.secrets.backend.pki.certificates.certificate.details',
};
case 'View issuer':
return {
title: 'Issuer',
placeholder: 'Type issuer name or ID',
buttonText: 'View issuer',
model: 'pki/issuer',
nameKey: 'issuerName',
route: 'vault.cluster.secrets.backend.pki.issuers.issuer.details',
};
default:
return {
placeholder: 'Please select an action above',
buttonText: 'Select an action',
model: '',
};
}
}

get filteredSecretEngines() {
return this.args.secretsEngines.filter((engine) => QUICK_ACTION_ENGINES.includes(engine.type));
}

get mountOptions() {
return this.filteredSecretEngines.map((engine) => {
const { id, type } = engine;
return { name: id, type, id };
});
}

@action
handleSearchEngineSelect([selection]) {
this.selectedEngine = selection?.type;
this.mountPath = selection?.id;
// reset tracked properties
this.selectedAction = null;
this.paramValue = null;
}

@action
setSelectedAction(selectedAction) {
this.selectedAction = selectedAction;
this.paramValue = null;
}

@action
handleActionSelect(val) {
if (Array.isArray(val)) {
this.paramValue = val[0];
} else {
this.paramValue = val;
}
}

@action
navigateToPage() {
let searchSelectParamRoute = this.searchSelectParams.route;

// kv has a special use case where if the paramValue ends in a '/' you should
// link to different route
if (this.selectedEngine === 'kv') {
searchSelectParamRoute =
this.paramValue && this.paramValue[this.paramValue?.length - 1] === '/'
? 'vault.cluster.secrets.backend.list'
: 'vault.cluster.secrets.backend.show';
}

this.router.transitionTo(searchSelectParamRoute, this.mountPath, this.paramValue);
}
}
63 changes: 63 additions & 0 deletions ui/app/templates/components/dashboard/quick-actions-card.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<h3 class="title is-4">Quick actions</h3>

<div class="has-bottom-margin-m">
<h4 class="title is-6">Secrets engines</h4>
<SearchSelect
@id="secrets-engines-select"
@options={{this.mountOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.handleSearchEngineSelect}}
@placeholder="Type to select a mount"
@displayInherit={{true}}
@shouldRenderName={{true}}
@passObject={{true}}
@objectKeys={{array "type"}}
class="is-marginless"
data-test-secrets-engines-select
/>
</div>

{{#if this.selectedEngine}}
<h4 class="title is-6">Action</h4>
<Select
@name="action-select"
@options={{this.actionOptions}}
@isFullwidth={{true}}
@selectedValue={{this.selectedAction}}
@onChange={{this.setSelectedAction}}
@noDefault={{true}}
/>

{{#if this.searchSelectParams.model}}
<h4 class="title is-6" data-test-search-select-params-title>{{this.searchSelectParams.title}}</h4>

<SearchSelect
class="is-flex-1"
@selectLimit="1"
@models={{array this.searchSelectParams.model}}
@backend={{this.mountPath}}
@placeholder={{this.searchSelectParams.placeholder}}
@disallowNewItems={{true}}
@onChange={{this.handleActionSelect}}
@fallbackComponent="input-search"
@nameKey={{this.searchSelectParams.nameKey}}
@disabled={{not this.searchSelectParams.model}}
/>

<div>
<button
type="button"
class="button is-primary has-top-margin-m"
disabled={{(not (and this.selectedAction this.selectedEngine this.paramValue))}}
{{on "click" this.navigateToPage}}
data-test-button={{this.searchSelectParams.buttonText}}
>
{{this.searchSelectParams.buttonText}}
</button>
</div>
{{/if}}
{{else}}
<EmptyState @title="No mount selected" @message="Select a mount above to get started." />
{{/if}}
2 changes: 1 addition & 1 deletion ui/app/templates/vault/cluster/dashboard.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
</Hds::Card::Container>

<Hds::Card::Container @hasBorder={{true}} class="has-padding-l is-flex-column is-flex-half">
<h3 class="title is-4">Quick Actions</h3>
<Dashboard::QuickActionsCard @secretsEngines={{@model.secretsEngines}} />
</Hds::Card::Container>

<Hds::Card::Container @hasBorder={{true}} class="has-padding-l is-flex-column is-flex-half">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ module('Acceptance | secrets/database/*', function (hooks) {
await authPage.logout();
// Check with restricted permissions
await authPage.login(token);
await click('[data-test-sidebar-nav-link="Secrets engines"]');
assert.dom(`[data-test-auth-backend-link="${backend}"]`).exists('Shows backend on secret list page');
await navToConnection(backend, connection);
assert.strictEqual(
Expand Down
132 changes: 132 additions & 0 deletions ui/tests/integration/components/dashboard/quick-actions-card-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { fillIn } from '@ember/test-helpers';

import { selectChoose } from 'ember-power-select/test-support/helpers';

// // TODO LANDING PAGE: create SELECTORS for the data-test attributes

module('Integration | Component | dashboard/quick-actions-card', function (hooks) {
setupRenderingTest(hooks);

hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'kubernetes_f3400dee',
path: 'kubernetes-test/',
type: 'kubernetes',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'database_f3400dee',
path: 'database-test/',
type: 'database',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_i1234dd',
path: 'apki-test/',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'secrets_j2350ii',
path: 'secrets-test/',
type: 'kv',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'nomad_123hh',
path: 'nomad/',
type: 'nomad',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_f3400dee',
path: 'pki-0-test/',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_i1234dd',
path: 'pki-1-test/',
description: 'pki-1-path-description',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'secrets_j2350ii',
path: 'secrets-1-test/',
type: 'kv',
},
});

this.secretsEngines = this.store.peekAll('secret-engine', {});

this.renderComponent = () => {
return render(hbs`<Dashboard::QuickActionsCard @secretsEngines={{this.secretsEngines}} />`);
};
});

test('it should show quick action empty state if no engine is selected', async function (assert) {
await this.renderComponent();
assert.dom('.title').hasText('Quick actions');
assert.dom('[data-test-secrets-engines-select]').exists({ count: 1 });
assert.dom('[data-test-component="empty-state"]').exists({ count: 1 });
});

test('it should show correct actions for pki', async function (assert) {
await this.renderComponent();
await selectChoose('.search-select', 'pki-0-test');
await fillIn('[data-test-select="action-select"]', 'Issue certificate');
assert.dom('[data-test-component="empty-state"]').doesNotExist();
await fillIn('[data-test-select="action-select"]', 'Issue certificate');
assert.dom('[data-test-button="Issue leaf certificate"]').exists({ count: 1 });
assert.dom('[data-test-search-select-params-title]').hasText('Role to use');
await fillIn('[data-test-select="action-select"]', 'View certificate');
assert.dom('[data-test-search-select-params-title]').hasText('Certificate serial number');
assert.dom('[data-test-button="View certificate"]').exists({ count: 1 });
await fillIn('[data-test-select="action-select"]', 'View issuer');
assert.dom('[data-test-search-select-params-title]').hasText('Issuer');
assert.dom('[data-test-button="View issuer"]').exists({ count: 1 });
});
test('it should show correct actions for database', async function (assert) {
await this.renderComponent();
await selectChoose('.search-select', 'database-test');
assert.dom('[data-test-component="empty-state"]').doesNotExist();
await fillIn('[data-test-select="action-select"]', 'Generate credentials for database');
assert.dom('[data-test-search-select-params-title]').hasText('Role to use');
assert.dom('[data-test-button="Generate credentials"]').exists({ count: 1 });
});
test('it should show correct actions for kv', async function (assert) {
await this.renderComponent();
await selectChoose('.search-select', 'secrets-1-test');
assert.dom('[data-test-component="empty-state"]').doesNotExist();
await fillIn('[data-test-select="action-select"]', 'Find KV secrets');
assert.dom('[data-test-search-select-params-title]').hasText('Secret Path');
assert.dom('[data-test-button="Read secrets"]').exists({ count: 1 });
});
});

0 comments on commit a9603f6

Please sign in to comment.