diff --git a/ui/app/styles/components/selectable-card-container.scss b/ui/app/styles/components/selectable-card-container.scss index 51c302d6e8..af4f7afee4 100644 --- a/ui/app/styles/components/selectable-card-container.scss +++ b/ui/app/styles/components/selectable-card-container.scss @@ -29,3 +29,12 @@ padding: $spacing-l 0 14px $spacing-l; // modify bottom spacing to better align with other cards } } + +.selectable-card-container.has-grid.has-two-col-grid { + grid-template-columns: 2fr 2fr; + grid-template-rows: none; +} +.selectable-card-container.has-grid.has-three-col-grid { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: none; +} diff --git a/ui/app/templates/components/input-search.hbs b/ui/lib/core/addon/components/input-search.hbs similarity index 100% rename from ui/app/templates/components/input-search.hbs rename to ui/lib/core/addon/components/input-search.hbs diff --git a/ui/app/components/input-search.js b/ui/lib/core/addon/components/input-search.js similarity index 100% rename from ui/app/components/input-search.js rename to ui/lib/core/addon/components/input-search.js diff --git a/ui/lib/core/addon/components/overview-card.hbs b/ui/lib/core/addon/components/overview-card.hbs new file mode 100644 index 0000000000..157a61a779 --- /dev/null +++ b/ui/lib/core/addon/components/overview-card.hbs @@ -0,0 +1,18 @@ +
+
+

{{@cardTitle}}

+ {{#if @actionText}} + + {{@actionText}} + + + {{/if}} +
+

{{@subText}}

+ {{yield}} +
\ No newline at end of file diff --git a/ui/lib/core/app/components/input-search.js b/ui/lib/core/app/components/input-search.js new file mode 100644 index 0000000000..a8e61ffbda --- /dev/null +++ b/ui/lib/core/app/components/input-search.js @@ -0,0 +1 @@ +export { default } from 'core/components/input-search'; diff --git a/ui/lib/core/app/components/overview-card.js b/ui/lib/core/app/components/overview-card.js new file mode 100644 index 0000000000..922f634809 --- /dev/null +++ b/ui/lib/core/app/components/overview-card.js @@ -0,0 +1 @@ +export { default } from 'core/components/overview-card'; diff --git a/ui/lib/pki/addon/components/page/pki-overview.hbs b/ui/lib/pki/addon/components/page/pki-overview.hbs new file mode 100644 index 0000000000..caf511b1ac --- /dev/null +++ b/ui/lib/pki/addon/components/page/pki-overview.hbs @@ -0,0 +1,82 @@ +
+ +

{{format-number (if (eq @issuers 404) 0 @issuers.length)}}

+
+ {{#if (not (eq @roles 403))}} + +

{{format-number (if (eq @roles 404) 0 @roles.length)}}

+
+ {{/if}} + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
\ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-overview.ts b/ui/lib/pki/addon/components/page/pki-overview.ts new file mode 100644 index 0000000000..2ee9451289 --- /dev/null +++ b/ui/lib/pki/addon/components/page/pki-overview.ts @@ -0,0 +1,55 @@ +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import Component from '@glimmer/component'; +// TYPES +import Store from '@ember-data/store'; +import RouterService from '@ember/routing/router-service'; +import PkiIssuerModel from 'vault/models/pki/issuer'; +import PkiRoleModel from 'vault/models/pki/role'; + +interface Args { + issuers: PkiIssuerModel | number; + roles: PkiRoleModel | number; + engine: string; +} + +export default class PkiOverview extends Component { + @service declare readonly router: RouterService; + @service declare readonly store: Store; + + @tracked rolesValue = ''; + @tracked certificateValue = ''; + + @action + transitionToViewCertificates(event: Event) { + event.preventDefault(); + this.router.transitionTo( + 'vault.cluster.secrets.backend.pki.certificates.certificate.details', + this.certificateValue + ); + } + @action + transitionToIssueCertificates(event: Event) { + event.preventDefault(); + this.router.transitionTo('vault.cluster.secrets.backend.pki.roles.role.generate', this.rolesValue); + } + + @action + handleRolesInput(roles: string) { + if (Array.isArray(roles)) { + this.rolesValue = roles[0]; + } else { + this.rolesValue = roles; + } + } + + @action + handleCertificateInput(certificate: string) { + if (Array.isArray(certificate)) { + this.certificateValue = certificate[0]; + } else { + this.certificateValue = certificate; + } + } +} diff --git a/ui/lib/pki/addon/routes/overview.js b/ui/lib/pki/addon/routes/overview.js index da7bb93c30..2a3457fc75 100644 --- a/ui/lib/pki/addon/routes/overview.js +++ b/ui/lib/pki/addon/routes/overview.js @@ -15,30 +15,46 @@ export default class PkiOverviewRoute extends Route { // When the engine is configured, it creates a default issuer. // If the issuers list is empty, we know it hasn't been configured const endpoint = `${this.win.origin}/v1/${this.secretMountPath.currentPath}/issuers?list=true`; + return this.auth .ajax(endpoint, 'GET', {}) .then(() => true) .catch(() => false); } + async fetchEngine() { + const model = await this.store.query('secret-engine', { + path: this.secretMountPath.currentPath, + }); + return model.get('firstObject'); + } + + async fetchAllRoles() { + try { + return await this.store.query('pki/role', { backend: this.secretMountPath.currentPath }); + } catch (e) { + return e.httpStatus; + } + } + + async fetchAllIssuers() { + try { + return await this.store.query('pki/issuer', { backend: this.secretMountPath.currentPath }); + } catch (e) { + return e.httpStatus; + } + } + async model() { return hash({ hasConfig: this.hasConfig(), - engine: this.store - .query('secret-engine', { - path: this.secretMountPath.currentPath, - }) - .then((model) => { - if (model) { - return model.get('firstObject'); - } - }), + engine: this.fetchEngine(), + roles: this.fetchAllRoles(), + issuers: this.fetchAllIssuers(), }); } setupController(controller, resolvedModel) { super.setupController(controller, resolvedModel); - const backend = this.secretMountPath.currentPath || 'pki'; - controller.breadcrumbs = [{ label: 'secrets', route: 'secrets', linkExternal: true }, { label: backend }]; } } diff --git a/ui/lib/pki/addon/templates/overview.hbs b/ui/lib/pki/addon/templates/overview.hbs index 7938b39154..f799f788ae 100644 --- a/ui/lib/pki/addon/templates/overview.hbs +++ b/ui/lib/pki/addon/templates/overview.hbs @@ -19,7 +19,7 @@ {{#if this.model.hasConfig}} - {{! TODO show overview items }} + {{else}} diff --git a/ui/tests/acceptance/pki/pki-engine-workflow-test.js b/ui/tests/acceptance/pki/pki-engine-workflow-test.js index a02fa42f97..2bb0e88129 100644 --- a/ui/tests/acceptance/pki/pki-engine-workflow-test.js +++ b/ui/tests/acceptance/pki/pki-engine-workflow-test.js @@ -1,42 +1,12 @@ -import { create } from 'ember-cli-page-object'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import authPage from 'vault/tests/pages/auth'; import logout from 'vault/tests/pages/logout'; import enablePage from 'vault/tests/pages/settings/mount-secret-backend'; -import consoleClass from 'vault/tests/pages/components/console/ui-panel'; import { click, currentURL, fillIn, find, isSettled, visit } from '@ember/test-helpers'; import { SELECTORS } from 'vault/tests/helpers/pki/workflow'; import { adminPolicy, readerPolicy, updatePolicy } from 'vault/tests/helpers/policy-generator/pki'; - -const consoleComponent = create(consoleClass); - -const tokenWithPolicy = async function (name, policy) { - await consoleComponent.runCommands([ - `write sys/policies/acl/${name} policy=${btoa(policy)}`, - `write -field=client_token auth/token/create policies=${name}`, - ]); - return consoleComponent.lastLogOutput; -}; - -const runCommands = async function (commands) { - try { - await consoleComponent.runCommands(commands); - const res = consoleComponent.lastLogOutput; - if (res.includes('Error')) { - throw new Error(res); - } - return res; - } catch (error) { - // eslint-disable-next-line no-console - console.error( - `The following occurred when trying to run the command(s):\n ${commands.join('\n')} \n\n ${ - consoleComponent.lastLogOutput - }` - ); - throw error; - } -}; +import { tokenWithPolicy, runCommands } from 'vault/tests/helpers/pki/pki-run-commands'; /** * This test module should test the PKI workflow, including: diff --git a/ui/tests/acceptance/pki/pki-overview-test.js b/ui/tests/acceptance/pki/pki-overview-test.js new file mode 100644 index 0000000000..150d9fd1e7 --- /dev/null +++ b/ui/tests/acceptance/pki/pki-overview-test.js @@ -0,0 +1,112 @@ +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import authPage from 'vault/tests/pages/auth'; +import logout from 'vault/tests/pages/logout'; +import enablePage from 'vault/tests/pages/settings/mount-secret-backend'; +import { click, currentURL, currentRouteName, visit } from '@ember/test-helpers'; +import { SELECTORS } from 'vault/tests/helpers/pki/overview'; +import { tokenWithPolicy, runCommands } from 'vault/tests/helpers/pki/pki-run-commands'; + +module('Acceptance | pki overview', function (hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(async function () { + await authPage.login(); + // Setup PKI engine + const mountPath = `pki`; + await enablePage.enable('pki', mountPath); + this.mountPath = mountPath; + await runCommands([`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test"`]); + const pki_admin_policy = ` + path "${this.mountPath}/*" { + capabilities = ["create", "read", "update", "delete", "list"] + }, + `; + const pki_issuers_list_policy = ` + path "${this.mountPath}/issuers" { + capabilities = ["list"] + }, + `; + const pki_roles_list_policy = ` + path "${this.mountPath}/roles" { + capabilities = ["list"] + }, + `; + + this.pkiRolesList = await tokenWithPolicy('pki-roles-list', pki_roles_list_policy); + this.pkiIssuersList = await tokenWithPolicy('pki-issuers-list', pki_issuers_list_policy); + this.pkiAdminToken = await tokenWithPolicy('pki-admin', pki_admin_policy); + await logout.visit(); + }); + + hooks.afterEach(async function () { + await logout.visit(); + await authPage.login(); + // Cleanup engine + await runCommands([`delete sys/mounts/${this.mountPath}`]); + await logout.visit(); + }); + + test('navigates to view issuers when link is clicked on issuer card', async function (assert) { + await authPage.login(this.pkiAdminToken); + await visit(`/vault/secrets/${this.mountPath}/pki/overview`); + assert.dom(SELECTORS.issuersCardTitle).hasText('Issuers'); + assert.dom(SELECTORS.issuersCardOverviewNum).hasText('1'); + await click(SELECTORS.issuersCardLink); + assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/issuers`); + await visit(`/vault/secrets/${this.mountPath}/pki/overview`); + }); + + test('navigates to view roles when link is clicked on roles card', async function (assert) { + await authPage.login(this.pkiAdminToken); + await visit(`/vault/secrets/${this.mountPath}/pki/overview`); + assert.dom(SELECTORS.rolesCardTitle).hasText('Roles'); + assert.dom(SELECTORS.rolesCardOverviewNum).hasText('0'); + await click(SELECTORS.rolesCardLink); + assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/roles`); + await runCommands([ + `write ${this.mountPath}/roles/some-role \ + issuer_ref="default" \ + allowed_domains="example.com" \ + allow_subdomains=true \ + max_ttl="720h"`, + ]); + await visit(`/vault/secrets/${this.mountPath}/pki/overview`); + assert.dom(SELECTORS.rolesCardOverviewNum).hasText('1'); + }); + + test('hides roles card if user does not have permissions', async function (assert) { + await authPage.login(this.pkiIssuersList); + await visit(`/vault/secrets/${this.mountPath}/pki/overview`); + assert.dom(SELECTORS.rolesCardTitle).doesNotExist('Roles card does not exist'); + assert.dom(SELECTORS.issuersCardTitle).exists('Issuers card exists'); + }); + + test('navigates to generate certificate page for Issue Certificates card', async function (assert) { + await authPage.login(this.pkiAdminToken); + await runCommands([ + `write ${this.mountPath}/roles/some-role \ + issuer_ref="default" \ + allowed_domains="example.com" \ + allow_subdomains=true \ + max_ttl="720h"`, + ]); + await visit(`/vault/secrets/${this.mountPath}/pki/overview`); + await click(SELECTORS.issueCertificatePowerSearch); + await click(SELECTORS.firstPowerSelectOption); + await click(SELECTORS.issueCertificateButton); + assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.roles.role.generate'); + }); + + test('navigates to certificate details page for View Certificates card', async function (assert) { + await authPage.login(this.pkiAdminToken); + await visit(`/vault/secrets/${this.mountPath}/pki/overview`); + await click(SELECTORS.viewCertificatePowerSearch); + await click(SELECTORS.firstPowerSelectOption); + await click(SELECTORS.viewCertificateButton); + assert.strictEqual( + currentRouteName(), + 'vault.cluster.secrets.backend.pki.certificates.certificate.details' + ); + }); +}); diff --git a/ui/tests/helpers/pki/overview.js b/ui/tests/helpers/pki/overview.js new file mode 100644 index 0000000000..10fcf8973e --- /dev/null +++ b/ui/tests/helpers/pki/overview.js @@ -0,0 +1,19 @@ +export const SELECTORS = { + issuersCardTitle: '[data-test-selectable-card-container="Issuers"] h3', + issuersCardSubtitle: '[data-test-selectable-card-container="Issuers"] p', + issuersCardLink: '[data-test-selectable-card-container="Issuers"] a', + issuersCardOverviewNum: '[data-test-selectable-card-container="Issuers"] .title-number', + rolesCardTitle: '[data-test-selectable-card-container="Roles"] h3', + rolesCardSubtitle: '[data-test-selectable-card-container="Roles"] p', + rolesCardLink: '[data-test-selectable-card-container="Roles"] a', + rolesCardOverviewNum: '[data-test-selectable-card-container="Roles"] .title-number', + issueCertificate: '[data-test-selectable-card-container="Issue certificate"] h3', + issueCertificateInput: '[data-test-issue-certificate-input]', + issueCertificatePowerSearch: '[data-test-issue-certificate-input] span', + issueCertificateButton: '[data-test-issue-certificate-button]', + viewCertificate: '[data-test-selectable-card-container="View certificate"] h3', + viewCertificateInput: '[data-test-view-certificate-input]', + viewCertificatePowerSearch: '[data-test-view-certificate-input] span', + viewCertificateButton: '[data-test-view-certificate-button]', + firstPowerSelectOption: '[data-option-index="0"]', +}; diff --git a/ui/tests/helpers/pki/pki-run-commands.js b/ui/tests/helpers/pki/pki-run-commands.js new file mode 100644 index 0000000000..3dd8cd8d84 --- /dev/null +++ b/ui/tests/helpers/pki/pki-run-commands.js @@ -0,0 +1,31 @@ +import consoleClass from 'vault/tests/pages/components/console/ui-panel'; +import { create } from 'ember-cli-page-object'; + +const consoleComponent = create(consoleClass); + +export const tokenWithPolicy = async function (name, policy) { + await consoleComponent.runCommands([ + `write sys/policies/acl/${name} policy=${btoa(policy)}`, + `write -field=client_token auth/token/create policies=${name}`, + ]); + return consoleComponent.lastLogOutput; +}; + +export const runCommands = async function (commands) { + try { + await consoleComponent.runCommands(commands); + const res = consoleComponent.lastLogOutput; + if (res.includes('Error')) { + throw new Error(res); + } + return res; + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `The following occurred when trying to run the command(s):\n ${commands.join('\n')} \n\n ${ + consoleComponent.lastLogOutput + }` + ); + throw error; + } +}; diff --git a/ui/tests/integration/components/overview-card-test.js b/ui/tests/integration/components/overview-card-test.js new file mode 100644 index 0000000000..ef4eb4b8f2 --- /dev/null +++ b/ui/tests/integration/components/overview-card-test.js @@ -0,0 +1,34 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +const CARD_TITLE = 'Card title'; +const ACTION_TEXT = 'View card'; +const SUBTEXT = 'This is subtext for card'; + +module('Integration | Component overview-card', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set('cardTitle', CARD_TITLE); + this.set('actionText', ACTION_TEXT); + this.set('subText', SUBTEXT); + }); + + test('it returns card title, ', async function (assert) { + await render(hbs``); + const titleText = this.element.querySelector('.title').innerText; + assert.strictEqual(titleText, 'Card title'); + }); + test('it returns card subtext, ', async function (assert) { + await render(hbs``); + const titleText = this.element.querySelector('p').innerText; + assert.strictEqual(titleText, 'This is subtext for card'); + }); + test('it returns card action text', async function (assert) { + await render(hbs``); + const titleText = this.element.querySelector('a').innerText; + assert.strictEqual(titleText, 'View card '); + }); +}); diff --git a/ui/tests/integration/components/pki/page/pki-overview-test.js b/ui/tests/integration/components/pki/page/pki-overview-test.js new file mode 100644 index 0000000000..55cfe852c4 --- /dev/null +++ b/ui/tests/integration/components/pki/page/pki-overview-test.js @@ -0,0 +1,77 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupEngine } from 'ember-engines/test-support'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { SELECTORS } from 'vault/tests/helpers/pki/overview'; + +module('Integration | Component | Page::PkiOverview', function (hooks) { + setupRenderingTest(hooks); + setupEngine(hooks, 'pki'); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.secretMountPath = this.owner.lookup('service:secret-mount-path'); + this.secretMountPath.currentPath = 'pki-test'; + + this.store.createRecord('pki/issuer', { issuerId: 'abcd-efgh' }); + this.store.createRecord('pki/issuer', { issuerId: 'ijkl-mnop' }); + this.store.createRecord('pki/role', { name: 'role-0' }); + this.store.createRecord('pki/role', { name: 'role-1' }); + this.store.createRecord('pki/role', { name: 'role-2' }); + this.store.createRecord('pki/certificate', { serialNumber: '22:2222:22222:2222' }); + this.store.createRecord('pki/certificate', { serialNumber: '33:3333:33333:3333' }); + + this.issuers = this.store.peekAll('pki/issuer'); + this.roles = this.store.peekAll('pki/role'); + this.engineId = 'pki'; + }); + + test('shows the correct information on issuer card', async function (assert) { + await render( + hbs`,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.issuersCardTitle).hasText('Issuers'); + assert.dom(SELECTORS.issuersCardOverviewNum).hasText('2'); + assert.dom(SELECTORS.issuersCardLink).hasText('View issuers'); + }); + + test('shows the correct information on roles card', async function (assert) { + await render( + hbs`,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.rolesCardTitle).hasText('Roles'); + assert.dom(SELECTORS.rolesCardOverviewNum).hasText('3'); + assert.dom(SELECTORS.rolesCardLink).hasText('View roles'); + this.roles = 404; + await render( + hbs`,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.rolesCardOverviewNum).hasText('0'); + }); + + test('shows the input search fields for View Certificates card', async function (assert) { + await render( + hbs`,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.issueCertificate).hasText('Issue certificate'); + assert.dom(SELECTORS.issueCertificateInput).exists(); + assert.dom(SELECTORS.issueCertificateButton).hasText('Issue'); + }); + + test('shows the input search fields for Issue Certificates card', async function (assert) { + await render( + hbs`,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.viewCertificate).hasText('View certificate'); + assert.dom(SELECTORS.viewCertificateInput).exists(); + assert.dom(SELECTORS.viewCertificateButton).hasText('View'); + }); +});