diff --git a/addons/api/addon/models/credential-store.js b/addons/api/addon/models/credential-store.js index 3b211b3b7..61ab62ecd 100644 --- a/addons/api/addon/models/credential-store.js +++ b/addons/api/addon/models/credential-store.js @@ -6,7 +6,13 @@ import GeneratedCredentialStoreModel from '../generated/models/credential-store'; import { equal } from '@ember/object/computed'; -export const types = ['static', 'vault']; +export const TYPE_CREDENTIAL_STORE_STATIC = 'static'; +export const TYPE_CREDENTIAL_STORE_VAULT = 'vault'; + +export const TYPES_CREDENTIAL_STORE = Object.freeze([ + TYPE_CREDENTIAL_STORE_STATIC, + TYPE_CREDENTIAL_STORE_VAULT, +]); export default class CredentialStoreModel extends GeneratedCredentialStoreModel { // =attributes diff --git a/addons/api/addon/services/indexed-db.js b/addons/api/addon/services/indexed-db.js index d63715152..707ddf8bd 100644 --- a/addons/api/addon/services/indexed-db.js +++ b/addons/api/addon/services/indexed-db.js @@ -16,6 +16,8 @@ export const modelIndexes = { group: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id', role: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id', + 'credential-store': + '&id, attributes.created_time, attributes.name, attributes.description, attributes.type, attributes.scope.scope_id', }; export const formatDbName = (userId, clusterUrl) => diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index 227948431..61a9a1903 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -610,11 +610,7 @@ function routes() { // credential-stores - this.get( - '/credential-stores', - ({ credentialStores }, { queryParams: { scope_id: scopeId } }) => - credentialStores.where({ scopeId }), - ); + this.get('/credential-stores'); this.get('/credential-stores/:id'); this.post('/credential-stores'); diff --git a/ui/admin/app/components/form/credential-store/static/index.js b/ui/admin/app/components/form/credential-store/static/index.js index 57bca3ed5..f001a64d9 100644 --- a/ui/admin/app/components/form/credential-store/static/index.js +++ b/ui/admin/app/components/form/credential-store/static/index.js @@ -4,7 +4,7 @@ */ import Component from '@glimmer/component'; -import { types } from 'api/models/credential-store'; +import { TYPES_CREDENTIAL_STORE } from 'api/models/credential-store'; //Note: this is a temporary solution till we have resource type helper in place const icons = ['keychain', 'vault']; @@ -16,6 +16,9 @@ export default class FormStaticCredentialStoreIndexComponent extends Component { * @type {object} */ get mapResourceTypeWithIcon() { - return types.reduce((obj, type, i) => ({ ...obj, [type]: icons[i] }), {}); + return TYPES_CREDENTIAL_STORE.reduce( + (obj, type, i) => ({ ...obj, [type]: icons[i] }), + {}, + ); } } diff --git a/ui/admin/app/components/form/credential-store/vault/index.js b/ui/admin/app/components/form/credential-store/vault/index.js index 2a1b5af90..cd24e3724 100644 --- a/ui/admin/app/components/form/credential-store/vault/index.js +++ b/ui/admin/app/components/form/credential-store/vault/index.js @@ -4,7 +4,7 @@ */ import Component from '@glimmer/component'; -import { types } from 'api/models/credential-store'; +import { TYPES_CREDENTIAL_STORE } from 'api/models/credential-store'; //Note: this is a temporary solution till we have resource type helper in place const icons = ['keychain', 'vault']; @@ -16,6 +16,9 @@ export default class FormVaultCredentialStoreIndexComponent extends Component { * @type {object} */ get mapResourceTypeWithIcon() { - return types.reduce((obj, type, i) => ({ ...obj, [type]: icons[i] }), {}); + return TYPES_CREDENTIAL_STORE.reduce( + (obj, type, i) => ({ ...obj, [type]: icons[i] }), + {}, + ); } } diff --git a/ui/admin/app/controllers/scopes/scope/credential-stores/index.js b/ui/admin/app/controllers/scopes/scope/credential-stores/index.js index 13bbece78..1d0182c05 100644 --- a/ui/admin/app/controllers/scopes/scope/credential-stores/index.js +++ b/ui/admin/app/controllers/scopes/scope/credential-stores/index.js @@ -6,15 +6,42 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { loading } from 'ember-loading'; import { confirm } from 'core/decorators/confirm'; +import { debounce } from 'core/decorators/debounce'; import { notifySuccess, notifyError } from 'core/decorators/notify'; +import { TYPES_CREDENTIAL_STORE } from 'api/models/credential-store'; export default class ScopesScopeCredentialStoresIndexController extends Controller { // =services @service can; @service router; + @service intl; + + // =attributes + + queryParams = ['search', { types: { type: 'array' } }, 'page', 'pageSize']; + + @tracked search; + @tracked types = []; + @tracked page = 1; + @tracked pageSize = 10; + + get credStoreTypeOptions() { + return TYPES_CREDENTIAL_STORE.map((type) => ({ + id: type, + name: this.intl.t(`resources.credential-store.types.${type}`), + })); + } + + get filters() { + return { + allFilters: { types: this.credStoreTypeOptions }, + selectedFilters: { types: this.types }, + }; + } // =actions @@ -75,4 +102,29 @@ export default class ScopesScopeCredentialStoresIndexController extends Controll async changeType(type) { await this.router.replaceWith({ queryParams: { type } }); } + + /** + * Handles input on each keystroke and the search queryParam + * @param {object} event + */ + @action + @debounce(250) + handleSearchInput(event) { + const { value } = event.target; + this.search = value; + this.page = 1; + } + + /** + * Sets a query param to the value of selectedItems + * and resets the page to 1. + * @param {string} paramKey + * @param {[string]} selectedItems + */ + @action + applyFilter(paramKey, selectedItems) { + console.log('appy filter', paramKey, selectedItems); + this[paramKey] = [...selectedItems]; + this.page = 1; + } } diff --git a/ui/admin/app/routes/scopes/scope/credential-stores.js b/ui/admin/app/routes/scopes/scope/credential-stores.js index f003547df..9d8cb283e 100644 --- a/ui/admin/app/routes/scopes/scope/credential-stores.js +++ b/ui/admin/app/routes/scopes/scope/credential-stores.js @@ -9,24 +9,15 @@ import { inject as service } from '@ember/service'; export default class ScopesScopeCredentialStoresRoute extends Route { // =services - @service store; - @service can; + @service router; + @service session; // =methods /** - * Load all credential stores under current scope - * @returns {Promise[CredentialStoreModel]} + * If arriving here unauthenticated, redirect to index for further processing. */ - async model() { - const scope = this.modelFor('scopes.scope'); - const { id: scope_id } = scope; - if ( - this.can.can('list model', scope, { - collection: 'credential-stores', - }) - ) { - return this.store.query('credential-store', { scope_id }); - } + beforeModel() { + if (!this.session.isAuthenticated) this.router.transitionTo('index'); } } diff --git a/ui/admin/app/routes/scopes/scope/credential-stores/index.js b/ui/admin/app/routes/scopes/scope/credential-stores/index.js index 6966e4997..0fb85eb97 100644 --- a/ui/admin/app/routes/scopes/scope/credential-stores/index.js +++ b/ui/admin/app/routes/scopes/scope/credential-stores/index.js @@ -4,9 +4,96 @@ */ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + export default class ScopesScopeCredentialStoresIndexRoute extends Route { + // =services + + @service store; + @service can; + + // =attributes + + queryParams = { + search: { + refreshModel: true, + replace: true, + }, + types: { + refreshModel: true, + replace: true, + }, + page: { + refreshModel: true, + }, + pageSize: { + refreshModel: true, + }, + }; + // =methods + /** + * Loads queried credential-stores and the number of credential-stores under current scope. + * @returns {Promise<{totalItems: number, credentialStores: [CredentialStoreModel], credentialStoresExist: boolean }> } + */ + async model({ search, types, page, pageSize }) { + const scope = this.modelFor('scopes.scope'); + const { id: scope_id } = scope; + + const filters = { + scope_id: [{ equals: scope_id }], + type: [], + }; + types.forEach((type) => { + filters.type.push({ equals: type }); + }); + + let credentialStores; + let totalItems = 0; + if ( + this.can.can('list model', scope, { + collection: 'credential-stores', + }) + ) { + credentialStores = await this.store.query('credential-store', { + query: { search, filters }, + page, + pageSize, + }); + totalItems = credentialStores.meta?.totalItems; + } + const credentialStoresExist = await this.getCredentialStoresExist( + scope_id, + totalItems, + ); + + return { credentialStores, credentialStoresExist, totalItems }; + } + + /** + * Sets credentialStoresExist to true if there exists any credential-stores. + * @param {string} scope_id + * @param {number} totalItems + * @returns {Promise} + */ + async getCredentialStoresExist(scope_id, totalItems) { + if (totalItems > 0) { + return true; + } + const options = { pushToStore: false }; + const credentialStore = await this.store.query( + 'credential-store', + { + query: { filters: { scope_id: [{ equals: scope_id }] } }, + page: 1, + pageSize: 1, + }, + options, + ); + return credentialStore.length > 0; + } + setupController(controller) { const scope = this.modelFor('scopes.scope'); super.setupController(...arguments); diff --git a/ui/admin/app/routes/scopes/scope/targets/target/add-brokered-credential-sources.js b/ui/admin/app/routes/scopes/scope/targets/target/add-brokered-credential-sources.js index c45d0eeac..f5aba962a 100644 --- a/ui/admin/app/routes/scopes/scope/targets/target/add-brokered-credential-sources.js +++ b/ui/admin/app/routes/scopes/scope/targets/target/add-brokered-credential-sources.js @@ -5,7 +5,6 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { all } from 'rsvp'; import { action } from '@ember/object'; import { loading } from 'ember-loading'; import { notifySuccess, notifyError } from 'core/decorators/notify'; @@ -35,9 +34,9 @@ export default class ScopesScopeTargetsTargetAddBrokeredCredentialSourcesRoute e const target = this.modelFor('scopes.scope.targets.target'); const { id: scope_id } = this.modelFor('scopes.scope'); const credentialStores = await this.store.query('credential-store', { - scope_id, + query: { filters: { scope_id: [{ equals: scope_id }] } }, }); - await all( + await Promise.all( credentialStores.map(({ id: credential_store_id, isStatic }) => { if (isStatic) { this.store.query('credential', { diff --git a/ui/admin/app/routes/scopes/scope/targets/target/add-injected-application-credential-sources.js b/ui/admin/app/routes/scopes/scope/targets/target/add-injected-application-credential-sources.js index 6b3c8e356..2395583a1 100644 --- a/ui/admin/app/routes/scopes/scope/targets/target/add-injected-application-credential-sources.js +++ b/ui/admin/app/routes/scopes/scope/targets/target/add-injected-application-credential-sources.js @@ -5,7 +5,6 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import { all } from 'rsvp'; import { action } from '@ember/object'; import { loading } from 'ember-loading'; import { notifySuccess, notifyError } from 'core/decorators/notify'; @@ -35,9 +34,9 @@ export default class ScopesScopeTargetsTargetAddInjectedApplicationCredentialSou const target = this.modelFor('scopes.scope.targets.target'); const { id: scope_id } = this.modelFor('scopes.scope'); const credentialStores = await this.store.query('credential-store', { - scope_id, + query: { filters: { scope_id: [{ equals: scope_id }] } }, }); - await all( + await Promise.all( credentialStores.map(({ id: credential_store_id, isStatic }) => { if (isStatic) { this.store.query('credential', { diff --git a/ui/admin/app/templates/scopes/scope/credential-stores/index.hbs b/ui/admin/app/templates/scopes/scope/credential-stores/index.hbs index e1980e422..c1b8545bd 100644 --- a/ui/admin/app/templates/scopes/scope/credential-stores/index.hbs +++ b/ui/admin/app/templates/scopes/scope/credential-stores/index.hbs @@ -13,7 +13,7 @@ {{t 'resources.credential-store.title_plural'}} - {{#if @model}} + {{#if @model.credentialStoresExist}}

{{t 'resources.credential-store.description'}}

{{/if}} @@ -21,100 +21,130 @@ {{#if (can 'create model' this.scope collection='credential-stores')}} - {{#if @model}} - - {{t 'resources.credential-store.titles.new'}} - + @text={{t 'resources.credential-store.titles.new'}} + /> {{/if}} {{/if}} - - {{#if @model}} + + {{#if @model.credentialStoresExist}} + + + + + + + - - <:body as |B|> - - - {{#if (can 'read credential-store' B.data)}} - - {{B.data.displayName}} - - {{else}} - - {{B.data.displayName}} - - {{/if}} - - {{B.data.description}} - - - - - - - - - - - - - {{else}} - - - - {{#if (can 'list model' this.scope collection='credential-stores')}} - {{! can list (at least): show default welcome message}} - {{t 'resources.credential-store.description'}} - {{else if - (can 'create model' this.scope collection='credential-stores') + <:body as |B|> + + + {{#if (can 'read credential-store' B.data)}} + + {{B.data.displayName}} + + {{else}} + + {{B.data.displayName}} + + {{/if}} + + {{B.data.description}} + + + + + + + + + + + + {{else}} + + + + + {{/if}} + {{else}} + + + - {{#if (can 'create model' this.scope collection='credential-stores')}} - - - {{t 'titles.new'}} - - {{/if}} - - - + ) + ) + resource=(t 'resources.credential-store.title_plural') + }} + /> + {{#if (can 'create model' this.scope collection='credential-stores')}} + + + + {{/if}} + {{/if}} diff --git a/ui/admin/tests/acceptance/credential-store/list-test.js b/ui/admin/tests/acceptance/credential-store/list-test.js index 839d39d28..bc87a063c 100644 --- a/ui/admin/tests/acceptance/credential-store/list-test.js +++ b/ui/admin/tests/acceptance/credential-store/list-test.js @@ -4,19 +4,32 @@ */ import { module, test } from 'qunit'; -import { visit, find } from '@ember/test-helpers'; +import { visit, click, fillIn, waitFor } from '@ember/test-helpers'; import { setupApplicationTest } from 'ember-qunit'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { setupIndexedDb } from 'api/test-support/helpers/indexed-db'; import { authenticateSession, // These are left here intentionally for future reference. //currentSession, //invalidateSession, } from 'ember-simple-auth/test-support'; +import { + TYPE_CREDENTIAL_STORE_STATIC, + TYPE_CREDENTIAL_STORE_VAULT, +} from 'api/models/credential-store'; module('Acceptance | credential-stores | list', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + setupIndexedDb(hooks); + + const SEARCH_INPUT_SELECTOR = '.search-filtering [type="search"]'; + const NO_RESULTS_MSG_SELECTOR = '[data-test-no-credential-store-results]'; + const FILTER_DROPDOWN_SELECTOR = (name) => + `.search-filtering [name="${name}"] button`; + const FILTER_APPLY_BUTTON_SELECTOR = + '.search-filtering [data-test-dropdown-apply-button]'; const instances = { scopes: { @@ -24,15 +37,16 @@ module('Acceptance | credential-stores | list', function (hooks) { org: null, project: null, }, + staticCredentialStore: null, + vaultCredentialStore: null, }; const urls = { - scopes: { - org: null, - }, + orgScope: null, projectScope: null, credentialStores: null, - credentialStore: null, + staticCredentialStore: null, + vaultCredentialStore: null, }; hooks.beforeEach(function () { @@ -45,51 +59,126 @@ module('Acceptance | credential-stores | list', function (hooks) { type: 'project', scope: { id: instances.scopes.org.id, type: 'org' }, }); - instances.credentialStore = this.server.create('credential-store', { + instances.staticCredentialStore = this.server.create('credential-store', { scope: instances.scopes.project, + type: TYPE_CREDENTIAL_STORE_STATIC, }); + instances.vaultCredentialStore = this.server.create('credential-store', { + scope: instances.scopes.project, + type: TYPE_CREDENTIAL_STORE_VAULT, + }); + urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; urls.projectScope = `/scopes/${instances.scopes.project.id}`; urls.credentialStores = `${urls.projectScope}/credential-stores`; + urls.staticCredentialStore = `${urls.credentialStores}/${instances.staticCredentialStore.id}`; + urls.vaultCredentialStore = `${urls.credentialStores}/${instances.vaultCredentialStore.id}`; + + const featuresService = this.owner.lookup('service:features'); + featuresService.enable('static-credentials'); authenticateSession({}); }); - test('Users can navigate to credential-stores with proper authorization', async function (assert) { - await visit(urls.projectScope); - assert.ok( + test('users can navigate to credential-stores with proper authorization', async function (assert) { + await visit(urls.orgScope); + + await click(`[href="${urls.projectScope}"]`); + + assert.true( instances.scopes.project.authorized_collection_actions[ 'credential-stores' ].includes('list'), ); - assert.ok(find(`[href="${urls.credentialStores}"]`)); + assert.dom(`[href="${urls.credentialStores}"]`).exists(); }); - test('Users cannot navigate to index without either list or create actions', async function (assert) { + test('users cannot navigate to index without either list or create actions', async function (assert) { instances.scopes.project.authorized_collection_actions[ 'credential-stores' ] = []; - await visit(urls.projectScope); - assert.notOk( + await visit(urls.orgScope); + + await click(`[href="${urls.projectScope}"]`); + + assert.false( instances.scopes.project.authorized_collection_actions[ 'credential-stores' ].includes('list'), ); - assert.notOk(find(`[href="${urls.credentialStores}"]`)); + assert.false( + instances.scopes.project.authorized_collection_actions[ + 'credential-stores' + ].includes('create'), + ); + assert.dom(`[href="${urls.credentialStores}"]`).doesNotExist(); }); - test('Users can navigate to index with only create action', async function (assert) { + test('users can navigate to index with only create action', async function (assert) { instances.scopes.project.authorized_collection_actions[ 'credential-stores' ] = ['create']; - await visit(urls.projectScope); - assert.ok(find(`[href="${urls.credentialStores}"]`)); + await visit(urls.orgScope); + + await click(`[href="${urls.projectScope}"]`); + + assert.dom(`[href="${urls.credentialStores}"]`).exists(); }); - test('Users can link to docs page for credential stores', async function (assert) { - await visit(urls.credentialStores); - assert.ok( - find( + test('users can link to docs page for credential stores', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.credentialStores}"]`); + + assert + .dom( `[href="https://boundaryproject.io/help/admin-ui/credential-stores"]`, - ), - ); + ) + .exists(); + }); + + test('user can search for a specific credential-store by id', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.credentialStores}"]`); + + assert.dom(`[href="${urls.staticCredentialStore}"]`).exists(); + assert.dom(`[href="${urls.vaultCredentialStore}"]`).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, instances.staticCredentialStore.id); + await waitFor(`[href="${urls.vaultCredentialStore}"]`, { count: 0 }); + + assert.dom(`[href="${urls.staticCredentialStore}"]`).exists(); + assert.dom(`[href="${urls.vaultCredentialStore}"]`).doesNotExist(); + }); + + test('user can search for credential-stores and get no results', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.credentialStores}"]`); + + assert.dom(`[href="${urls.staticCredentialStore}"]`).exists(); + assert.dom(`[href="${urls.vaultCredentialStore}"]`).exists(); + + await fillIn(SEARCH_INPUT_SELECTOR, 'fake cred store that does not exist'); + await waitFor(NO_RESULTS_MSG_SELECTOR, { count: 1 }); + + assert.dom(`[href="${urls.staticCredentialStore}"]`).doesNotExist(); + assert.dom(`[href="${urls.vaultCredentialStore}"]`).doesNotExist(); + assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found'); + }); + + test('user can filter for credential-stores by type', async function (assert) { + await visit(urls.projectScope); + + await click(`[href="${urls.credentialStores}"]`); + + assert.dom(`[href="${urls.staticCredentialStore}"]`).exists(); + assert.dom(`[href="${urls.vaultCredentialStore}"]`).exists(); + + await click(FILTER_DROPDOWN_SELECTOR('Type')); + await click(`input[value="${TYPE_CREDENTIAL_STORE_VAULT}"]`); + await click(FILTER_APPLY_BUTTON_SELECTOR); + + assert.dom(`[href="${urls.staticCredentialStore}"]`).doesNotExist(); + assert.dom(`[href="${urls.vaultCredentialStore}"]`).exists(); }); }); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/credential-stores/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/credential-stores/index-test.js index 1e7cfd5a2..1aead8903 100644 --- a/ui/admin/tests/unit/controllers/scopes/scope/credential-stores/index-test.js +++ b/ui/admin/tests/unit/controllers/scopes/scope/credential-stores/index-test.js @@ -1,21 +1,55 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import { waitUntil } from '@ember/test-helpers'; +import { + TYPE_CREDENTIAL_STORE_STATIC, + TYPE_CREDENTIAL_STORE_VAULT, +} from 'api/models/credential-store'; module( 'Unit | Controller | scopes/scope/credential-stores/index', function (hooks) { setupTest(hooks); - test('it exists', function (assert) { - let controller = this.owner.lookup( + let controller; + + hooks.beforeEach(function () { + controller = this.owner.lookup( 'controller:scopes/scope/credential-stores/index', ); + }); + + test('it exists', function (assert) { assert.ok(controller); }); + + test('credStoreTypeOptions returns expected object', function (assert) { + assert.deepEqual(controller.credStoreTypeOptions, [ + { id: TYPE_CREDENTIAL_STORE_STATIC, name: 'Static' }, + { id: TYPE_CREDENTIAL_STORE_VAULT, name: 'Vault' }, + ]); + }); + + test('filters returns expected entries', function (assert) { + assert.ok(controller.filters.allFilters); + assert.ok(controller.filters.selectedFilters); + }); + + test('handleSearchInput action sets expected values correctly', async function (assert) { + const searchValue = 'test'; + controller.handleSearchInput({ target: { value: searchValue } }); + await waitUntil(() => controller.search === searchValue); + + assert.strictEqual(controller.page, 1); + assert.strictEqual(controller.search, searchValue); + }); + + test('applyFilter action sets expected values correctly', async function (assert) { + const selectedItems = ['vault']; + controller.applyFilter('types', selectedItems); + + assert.strictEqual(controller.page, 1); + assert.deepEqual(controller.types, selectedItems); + }); }, );