diff --git a/ui/lib/core/addon/components/page/error.hbs b/ui/lib/core/addon/components/page/error.hbs new file mode 100644 index 000000000..b69dc7e8c --- /dev/null +++ b/ui/lib/core/addon/components/page/error.hbs @@ -0,0 +1,25 @@ +
+ {{#if (eq @error.httpStatus 404)}} +

+ 404 Not Found +

+

Sorry, we were unable to find any content at {{@error.path}}.

+ {{else if (eq @error.httpStatus 403)}} +

+ Not authorized +

+

You are not authorized to access content at {{@error.path}}.

+ {{else}} +

+ Error +

+

+ {{#if @error.message}} +

{{@error.message}}

+ {{/if}} + {{#each @error.errors as |error index|}} +

{{error}}

+ {{/each}} +

+ {{/if}} +
\ No newline at end of file diff --git a/ui/lib/core/app/components/page/error.js b/ui/lib/core/app/components/page/error.js new file mode 100644 index 000000000..6ff28f624 --- /dev/null +++ b/ui/lib/core/app/components/page/error.js @@ -0,0 +1 @@ +export { default } from 'core/components/page/error'; diff --git a/ui/lib/kubernetes/addon/components/page/overview.hbs b/ui/lib/kubernetes/addon/components/page/overview.hbs index d4519ccf0..228135f3f 100644 --- a/ui/lib/kubernetes/addon/components/page/overview.hbs +++ b/ui/lib/kubernetes/addon/components/page/overview.hbs @@ -2,7 +2,9 @@ Configure Kubernetes -{{#if @config}} +{{#if @promptConfig}} + +{{else}}
-{{else}} - {{/if}} \ No newline at end of file diff --git a/ui/lib/kubernetes/addon/components/page/overview.js b/ui/lib/kubernetes/addon/components/page/overview.js index 649926d16..a725c1416 100644 --- a/ui/lib/kubernetes/addon/components/page/overview.js +++ b/ui/lib/kubernetes/addon/components/page/overview.js @@ -7,7 +7,7 @@ import { action } from '@ember/object'; * @module Overview * OverviewPage component is a child component to overview kubernetes secrets engine. * - * @param {object} config - config model that contains kubernetes configuration + * @param {boolean} promptConfig - whether or not to display config cta * @param {object} backend - backend model that contains kubernetes configuration * @param {array} roles - array of roles * @param {array} breadcrumbs - breadcrumbs as an array of objects that contain label and route diff --git a/ui/lib/kubernetes/addon/components/page/roles.hbs b/ui/lib/kubernetes/addon/components/page/roles.hbs index 8feff9dd5..960b249b9 100644 --- a/ui/lib/kubernetes/addon/components/page/roles.hbs +++ b/ui/lib/kubernetes/addon/components/page/roles.hbs @@ -1,10 +1,17 @@ - - - Create role - + + {{#unless @promptConfig}} + + Create role + + {{/unless}} -{{#if (not @config)}} +{{#if @promptConfig}} {{else if (not @roles)}} {{#if @filterValue}} diff --git a/ui/lib/kubernetes/addon/components/page/roles.js b/ui/lib/kubernetes/addon/components/page/roles.js index dd846fe28..c77680e50 100644 --- a/ui/lib/kubernetes/addon/components/page/roles.js +++ b/ui/lib/kubernetes/addon/components/page/roles.js @@ -9,7 +9,7 @@ import errorMessage from 'vault/utils/error-message'; * RolesPage component is a child component to show list of roles * * @param {array} roles - array of roles - * @param {object} config - config model that contains kubernetes configuration + * @param {boolean} promptConfig - whether or not to display config cta * @param {array} pageFilter - array of filtered roles * @param {array} breadcrumbs - breadcrumbs as an array of objects that contain label and route */ diff --git a/ui/lib/kubernetes/addon/decorators/fetch-config.js b/ui/lib/kubernetes/addon/decorators/fetch-config.js new file mode 100644 index 000000000..feb4d182d --- /dev/null +++ b/ui/lib/kubernetes/addon/decorators/fetch-config.js @@ -0,0 +1,49 @@ +import Route from '@ember/routing/route'; + +/** + * the overview, configure, configuration and roles routes all need to be aware of the config for the engine + * if the user has not configured they are prompted to do so in each of the routes + * decorate the necessary routes to perform the check in the beforeModel hook since that may change what is returned for the model + */ + +export function withConfig() { + return function decorator(SuperClass) { + if (!Object.prototype.isPrototypeOf.call(Route, SuperClass)) { + // eslint-disable-next-line + console.error( + 'withConfig decorator must be used on an instance of ember Route class. Decorator not applied to returned class' + ); + return SuperClass; + } + return class FetchConfig extends SuperClass { + configModel = null; + configError = null; + promptConfig = false; + + async beforeModel() { + super.beforeModel(...arguments); + + const backend = this.secretMountPath.get(); + // check the store for record first + this.configModel = this.store.peekRecord('kubernetes/config', backend); + if (!this.configModel) { + return this.store + .queryRecord('kubernetes/config', { backend }) + .then((record) => { + this.configModel = record; + }) + .catch((error) => { + // we need to ignore if the user does not have permission or other failures so as to not block the other operations + if (error.httpStatus === 404) { + this.promptConfig = true; + } else { + // not considering 404 an error since it triggers the cta + // this error is thrown in the configuration route so we can display the error in the view + this.configError = error; + } + }); + } + } + }; + }; +} diff --git a/ui/lib/kubernetes/addon/routes/configuration.js b/ui/lib/kubernetes/addon/routes/configuration.js index 6c53330b9..a265e8d84 100644 --- a/ui/lib/kubernetes/addon/routes/configuration.js +++ b/ui/lib/kubernetes/addon/routes/configuration.js @@ -1,7 +1,17 @@ -import FetchConfigRoute from './fetch-config'; +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from '../decorators/fetch-config'; + +@withConfig() +export default class KubernetesConfigureRoute extends Route { + @service store; + @service secretMountPath; -export default class KubernetesConfigureRoute extends FetchConfigRoute { model() { + // in case of any error other than 404 we want to display that to the user + if (this.configError) { + throw this.configError; + } return { backend: this.modelFor('application'), config: this.configModel, diff --git a/ui/lib/kubernetes/addon/routes/configure.js b/ui/lib/kubernetes/addon/routes/configure.js index 3ac8b1907..e728ff88c 100644 --- a/ui/lib/kubernetes/addon/routes/configure.js +++ b/ui/lib/kubernetes/addon/routes/configure.js @@ -1,6 +1,12 @@ -import FetchConfigRoute from './fetch-config'; +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from '../decorators/fetch-config'; + +@withConfig() +export default class KubernetesConfigureRoute extends Route { + @service store; + @service secretMountPath; -export default class KubernetesConfigureRoute extends FetchConfigRoute { async model() { const backend = this.secretMountPath.get(); return this.configModel || this.store.createRecord('kubernetes/config', { backend }); diff --git a/ui/lib/kubernetes/addon/routes/error.js b/ui/lib/kubernetes/addon/routes/error.js new file mode 100644 index 000000000..09bb49e3f --- /dev/null +++ b/ui/lib/kubernetes/addon/routes/error.js @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class KubernetesErrorRoute extends Route { + @service secretMountPath; + + setupController(controller) { + super.setupController(...arguments); + controller.breadcrumbs = [ + { label: 'secrets', route: 'secrets', linkExternal: true }, + { label: this.secretMountPath.currentPath, route: 'overview' }, + ]; + controller.backend = this.modelFor('application'); + } +} diff --git a/ui/lib/kubernetes/addon/routes/fetch-config.js b/ui/lib/kubernetes/addon/routes/fetch-config.js deleted file mode 100644 index 3a846d91a..000000000 --- a/ui/lib/kubernetes/addon/routes/fetch-config.js +++ /dev/null @@ -1,31 +0,0 @@ -import Route from '@ember/routing/route'; -import { inject as service } from '@ember/service'; - -/** - * the overview, configure, configuration and roles routes all need to be aware of the config for the engine - * if the user has not configured they are prompted to do so in each of the routes - * this route can be extended so the check happens in the beforeModel hook since that may change what is returned from the model hook - */ - -export default class KubernetesFetchConfigRoute extends Route { - @service store; - @service secretMountPath; - - configModel = null; - - async beforeModel() { - const backend = this.secretMountPath.get(); - // check the store for record first - this.configModel = this.store.peekRecord('kubernetes/config', backend); - if (!this.configModel) { - return this.store - .queryRecord('kubernetes/config', { backend }) - .then((record) => { - this.configModel = record; - }) - .catch(() => { - // it's ok! we don't need to transition to the error route - }); - } - } -} diff --git a/ui/lib/kubernetes/addon/routes/overview.js b/ui/lib/kubernetes/addon/routes/overview.js index bcae6f5e9..e86d1368e 100644 --- a/ui/lib/kubernetes/addon/routes/overview.js +++ b/ui/lib/kubernetes/addon/routes/overview.js @@ -1,11 +1,17 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from 'kubernetes/decorators/fetch-config'; import { hash } from 'rsvp'; -import FetchConfigRoute from './fetch-config'; -export default class KubernetesOverviewRoute extends FetchConfigRoute { +@withConfig() +export default class KubernetesOverviewRoute extends Route { + @service store; + @service secretMountPath; + async model() { const backend = this.secretMountPath.get(); return hash({ - config: this.configModel, + promptConfig: this.promptConfig, backend: this.modelFor('application'), roles: this.store.query('kubernetes/role', { backend }).catch(() => []), }); diff --git a/ui/lib/kubernetes/addon/routes/roles/index.js b/ui/lib/kubernetes/addon/routes/roles/index.js index da7a4e4ae..5cd426ba3 100644 --- a/ui/lib/kubernetes/addon/routes/roles/index.js +++ b/ui/lib/kubernetes/addon/routes/roles/index.js @@ -1,7 +1,13 @@ -import FetchConfigRoute from '../fetch-config'; +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { withConfig } from 'kubernetes/decorators/fetch-config'; import { hash } from 'rsvp'; -export default class KubernetesRolesRoute extends FetchConfigRoute { +@withConfig() +export default class KubernetesRolesRoute extends Route { + @service store; + @service secretMountPath; + model(params, transition) { // filter roles based on pageFilter value const { pageFilter } = transition.to.queryParams; @@ -12,10 +18,15 @@ export default class KubernetesRolesRoute extends FetchConfigRoute { ? models.filter((model) => model.name.toLowerCase().includes(pageFilter.toLowerCase())) : models ) - .catch(() => []); + .catch((error) => { + if (error.httpStatus === 404) { + return []; + } + throw error; + }); return hash({ backend: this.modelFor('application'), - config: this.configModel, + promptConfig: this.promptConfig, roles, }); } diff --git a/ui/lib/kubernetes/addon/templates/error.hbs b/ui/lib/kubernetes/addon/templates/error.hbs new file mode 100644 index 000000000..ec31dbdc4 --- /dev/null +++ b/ui/lib/kubernetes/addon/templates/error.hbs @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/lib/kubernetes/addon/templates/overview.hbs b/ui/lib/kubernetes/addon/templates/overview.hbs index 2d1aeec8b..477d635b7 100644 --- a/ui/lib/kubernetes/addon/templates/overview.hbs +++ b/ui/lib/kubernetes/addon/templates/overview.hbs @@ -1,5 +1,5 @@ -
- {{#if (eq this.model.httpStatus 404)}} -

- 404 Not Found -

-

Sorry, we were unable to find any content at {{or this.model.path this.path}}.

- {{else if (eq this.model.httpStatus 403)}} -

- Not authorized -

-

You are not authorized to access content at {{or this.model.path this.path}}.

- {{else}} -

- Error -

-

- {{#if this.model.message}} -

{{this.model.message}}

- {{/if}} - {{#each this.model.errors as |error|}} -

{{error}}

- {{/each}} -

- {{/if}} -
\ No newline at end of file + \ No newline at end of file diff --git a/ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js b/ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js index e0ed5ad99..e2ec651e6 100644 --- a/ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/kubernetes/configuration-test.js @@ -5,6 +5,7 @@ import kubernetesScenario from 'vault/mirage/scenarios/kubernetes'; import ENV from 'vault/config/environment'; import authPage from 'vault/tests/pages/auth'; import { visit, click, currentRouteName } from '@ember/test-helpers'; +import { Response } from 'miragejs'; module('Acceptance | kubernetes | configuration', function (hooks) { setupApplicationTest(hooks); @@ -13,6 +14,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) { hooks.before(function () { ENV['ember-cli-mirage'].handler = 'kubernetes'; }); + hooks.beforeEach(function () { kubernetesScenario(this.server); this.visitConfiguration = () => { @@ -23,6 +25,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) { }; return authPage.login(); }); + hooks.after(function () { ENV['ember-cli-mirage'].handler = null; }); @@ -33,6 +36,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) { await click('[data-test-toolbar-config-action]'); this.validateRoute(assert, 'configure', 'Transitions to Configure route on click'); }); + test('it should transition to the configuration page on Save click in Configure', async function (assert) { assert.expect(1); await this.visitConfiguration(); @@ -41,6 +45,7 @@ module('Acceptance | kubernetes | configuration', function (hooks) { await click('[data-test-config-confirm]'); this.validateRoute(assert, 'configuration', 'Transitions to Configuration route on click'); }); + test('it should transition to the configuration page on Cancel click in Configure', async function (assert) { assert.expect(1); await this.visitConfiguration(); @@ -48,4 +53,25 @@ module('Acceptance | kubernetes | configuration', function (hooks) { await click('[data-test-config-cancel]'); this.validateRoute(assert, 'configuration', 'Transitions to Configuration route on click'); }); + + test('it should transition to error route on config fetch error other than 404', async function (assert) { + this.server.get('/kubernetes/config', () => new Response(403)); + await this.visitConfiguration(); + assert.strictEqual( + currentRouteName(), + 'vault.cluster.secrets.backend.kubernetes.error', + 'Transitions to error route on config fetch error' + ); + }); + + test('it should not transition to error route on config fetch 404', async function (assert) { + this.server.get('/kubernetes/config', () => new Response(404)); + await this.visitConfiguration(); + assert.strictEqual( + currentRouteName(), + 'vault.cluster.secrets.backend.kubernetes.configuration', + 'Transitions to configuration route on fetch 404' + ); + assert.dom('[data-test-empty-state-title]').hasText('Kubernetes not configured', 'Config cta renders'); + }); }); diff --git a/ui/tests/integration/components/kubernetes/page/overview-test.js b/ui/tests/integration/components/kubernetes/page/overview-test.js index 4295200a4..202493efa 100644 --- a/ui/tests/integration/components/kubernetes/page/overview-test.js +++ b/ui/tests/integration/components/kubernetes/page/overview-test.js @@ -22,11 +22,6 @@ module('Integration | Component | kubernetes | Page::Overview', function (hooks) type: 'kubernetes', }, }); - this.store.pushPayload('kubernetes/config', { - modelName: 'kubernetes/config', - backend: 'kubernetes-test', - ...this.server.create('kubernetes-config'), - }); this.store.pushPayload('kubernetes/role', { modelName: 'kubernetes/role', backend: 'kubernetes-test', @@ -38,15 +33,15 @@ module('Integration | Component | kubernetes | Page::Overview', function (hooks) ...this.server.create('kubernetes-role'), }); this.backend = this.store.peekRecord('secret-engine', 'kubernetes-test'); - this.config = this.store.peekRecord('kubernetes/config', 'kubernetes-test'); this.roles = this.store.peekAll('kubernetes/role'); this.breadcrumbs = [ { label: 'secrets', route: 'secrets', linkExternal: true }, { label: this.backend.id }, ]; + this.promptConfig = false; this.renderComponent = () => { return render( - hbs``, + hbs``, { owner: this.engine } ); }; @@ -98,7 +93,7 @@ module('Integration | Component | kubernetes | Page::Overview', function (hooks) }); test('it should show ConfigCta when no config is set up', async function (assert) { - this.config = null; + this.promptConfig = true; await this.renderComponent(); assert.dom(SELECTORS.emptyStateTitle).hasText('Kubernetes not configured'); diff --git a/ui/tests/integration/components/kubernetes/page/roles-test.js b/ui/tests/integration/components/kubernetes/page/roles-test.js index 6d29c3570..f65995bee 100644 --- a/ui/tests/integration/components/kubernetes/page/roles-test.js +++ b/ui/tests/integration/components/kubernetes/page/roles-test.js @@ -20,49 +20,50 @@ module('Integration | Component | kubernetes | Page::Roles', function (hooks) { type: 'kubernetes', }, }); - this.store.pushPayload('kubernetes/config', { - modelName: 'kubernetes/config', - backend: 'kubernetes-test', - ...this.server.create('kubernetes-config'), - }); this.store.pushPayload('kubernetes/role', { modelName: 'kubernetes/role', backend: 'kubernetes-test', ...this.server.create('kubernetes-role'), }); this.backend = this.store.peekRecord('secret-engine', 'kubernetes-test'); - this.config = this.store.peekRecord('kubernetes/config', 'kubernetes-test'); this.roles = this.store.peekAll('kubernetes/role'); this.filterValue = ''; this.breadcrumbs = [ { label: 'secrets', route: 'secrets', linkExternal: true }, { label: this.backend.id }, ]; + this.promptConfig = false; this.renderComponent = () => { return render( - hbs``, + hbs``, { owner: this.engine } ); }; }); test('it should render tab page header and config cta', async function (assert) { - this.config = null; + this.promptConfig = true; await this.renderComponent(); assert.dom('.title svg').hasClass('flight-icon-kubernetes', 'Kubernetes icon renders in title'); assert.dom('.title').hasText('kubernetes-test', 'Mount path renders in title'); - assert.dom('[data-test-toolbar-roles-action]').hasText('Create role', 'Toolbar action has correct text'); assert - .dom('[data-test-toolbar-roles-action] svg') - .hasClass('flight-icon-plus', 'Toolbar action has correct icon'); - assert.dom('[data-test-nav-input]').exists('Roles filter input renders'); + .dom('[data-test-toolbar-roles-action]') + .doesNotExist('Create role', 'Toolbar action does not render when not configured'); + assert + .dom('[data-test-nav-input]') + .doesNotExist('Roles filter input does not render when not configured'); assert.dom('[data-test-config-cta]').exists('Config cta renders'); }); test('it should render create roles cta', async function (assert) { this.roles = null; await this.renderComponent(); + assert.dom('[data-test-toolbar-roles-action]').hasText('Create role', 'Toolbar action has correct text'); + assert + .dom('[data-test-toolbar-roles-action] svg') + .hasClass('flight-icon-plus', 'Toolbar action has correct icon'); + assert.dom('[data-test-nav-input]').exists('Roles filter input renders'); assert.dom('[data-test-empty-state-title]').hasText('No roles yet', 'Title renders'); assert .dom('[data-test-empty-state-message]') diff --git a/ui/tests/integration/components/page/error-test.js b/ui/tests/integration/components/page/error-test.js new file mode 100644 index 000000000..a677a2ddd --- /dev/null +++ b/ui/tests/integration/components/page/error-test.js @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | page/error', function (hooks) { + setupRenderingTest(hooks); + + test('it should render 404 error', async function (assert) { + this.error = { + httpStatus: 404, + path: '/v1/kubernetes/config', + }; + + await render(hbs``); + + assert.dom('h1').hasText('404 Not Found', 'Error title renders'); + assert + .dom('p') + .hasText(`Sorry, we were unable to find any content at ${this.error.path}.`, 'Error message renders'); + }); + + test('it should render 403 error', async function (assert) { + this.error = { + httpStatus: 403, + path: '/v1/kubernetes/config', + }; + + await render(hbs``); + + assert.dom('h1').hasText('Not authorized', 'Error title renders'); + assert + .dom('p') + .hasText(`You are not authorized to access content at ${this.error.path}.`, 'Error message renders'); + }); + + test('it should render general error', async function (assert) { + this.error = { + message: 'An unexpected error occurred', + errors: ['This is one thing that went wrong', 'Unfortunately something else went wrong too'], + }; + + await render(hbs``); + + assert.dom('h1').hasText('Error', 'Error title renders'); + assert.dom('[data-test-page-error-message]').hasText(this.error.message, 'Error message renders'); + this.error.errors.forEach((error, index) => { + assert + .dom(`[data-test-page-error-details="${index}"]`) + .hasText(this.error.errors[index], 'Error detail renders'); + }); + }); +});