From ba786d217202a154a7d735fad59f597cd6e0ae5d Mon Sep 17 00:00:00 2001 From: Dharini Jeeva Date: Fri, 12 Jan 2024 15:01:16 -0800 Subject: [PATCH] policy association form (#2089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 add storage policy api and mirage --- addons/api/mirage/config.js | 22 +++ addons/core/translations/resources/en-us.yaml | 10 +- .../form/scope/add-storage-policy/index.hbs | 62 +++++++ .../form/scope/add-storage-policy/index.js | 19 +++ ui/admin/app/router.js | 3 + ui/admin/app/routes/scopes.js | 19 +++ .../scopes/scope/add-storage-policy/create.js | 58 +++++++ .../scopes/scope/add-storage-policy/index.js | 76 +++++++++ ui/admin/app/routes/scopes/scope/edit.js | 16 +- ui/admin/app/styles/app.scss | 34 ++++ .../scope/add-storage-policy/create.hbs | 31 ++++ .../scopes/scope/add-storage-policy/index.hbs | 31 ++++ ui/admin/app/templates/scopes/scope/edit.hbs | 129 +++++++++++++-- .../scopes/add-storage-policy-test.js | 156 ++++++++++++++++++ .../scope/add-storage-policy/create-test.js | 21 +++ .../scope/add-storage-policy/index-test.js | 18 ++ 16 files changed, 688 insertions(+), 17 deletions(-) create mode 100644 ui/admin/app/components/form/scope/add-storage-policy/index.hbs create mode 100644 ui/admin/app/components/form/scope/add-storage-policy/index.js create mode 100644 ui/admin/app/routes/scopes/scope/add-storage-policy/create.js create mode 100644 ui/admin/app/routes/scopes/scope/add-storage-policy/index.js create mode 100644 ui/admin/app/templates/scopes/scope/add-storage-policy/create.hbs create mode 100644 ui/admin/app/templates/scopes/scope/add-storage-policy/index.hbs create mode 100644 ui/admin/tests/acceptance/scopes/add-storage-policy-test.js create mode 100644 ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/create-test.js create mode 100644 ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/index-test.js diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index 41efe450c..9d9720af2 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -88,7 +88,29 @@ function routes() { this.get('/scopes/:id'); this.patch('/scopes/:id'); this.del('/scopes/:id'); + this.post( + '/scopes/:idMethod', + function ({ scopes }, { params: { idMethod } }) { + const attrs = this.normalizedRequestAttrs(); + const id = idMethod.split(':')[0]; + const method = idMethod.split(':')[1]; + const scope = scopes.find(id); + const updatedAttrs = { + version: attrs.version, + storagePolicyId: attrs.storagePolicyId, + }; + + if (method === 'attach-storage-policy') { + updatedAttrs.storagePolicyId = attrs.storagePolicyId; + } + + if (method === 'detach-storage-policy') { + updatedAttrs.storagePolicyId = ''; + } + return scope.update(updatedAttrs); + }, + ); // Auth & IAM resources this.get( diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 8b37a1135..419a35089 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -922,13 +922,21 @@ policy: new: New Storage Policy policy_name: Policy name retain_for: Retain for - delete_after: Delete After + delete_after: Delete after do_not_delete: Do not delete forever: Forever days: days + policy_description: Store recorded sessions for at least this long. + create_policy_description: Enable session recording for projects in this org. actions: delete: Delete Storage Policy + add: Add Storage Policy + apply: Apply the storage policy to this scope and its children + new: Add a new storage policy + questions: + detach: Are you sure you want to detach this storage policy? messages: + detach: Successfully detached none: title: No storage policies yet description: Storage policies define how long to store recordings before they are automatically deleted. We recommend checking your data retention policies for compliance requirements. diff --git a/ui/admin/app/components/form/scope/add-storage-policy/index.hbs b/ui/admin/app/components/form/scope/add-storage-policy/index.hbs new file mode 100644 index 000000000..d3d6b4d8d --- /dev/null +++ b/ui/admin/app/components/form/scope/add-storage-policy/index.hbs @@ -0,0 +1,62 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + {{t 'resources.policy.title'}} + + {{t 'resources.policy.actions.apply'}} + + + + {{#each @policyList as |policy|}} + + {{/each}} + + {{#if @model.errors.storage_policy_id}} + + {{#each @model.errors.storage_policy_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + + + \ No newline at end of file diff --git a/ui/admin/app/components/form/scope/add-storage-policy/index.js b/ui/admin/app/components/form/scope/add-storage-policy/index.js new file mode 100644 index 000000000..f0ab02fc7 --- /dev/null +++ b/ui/admin/app/components/form/scope/add-storage-policy/index.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class FormAddStoragePolicyIndexComponent extends Component { + //actions + + /** + * select storage policy + */ + @action + selectPolicy({ target: { value: policyId } }) { + this.args.model.storage_policy_id = policyId; + } +} diff --git a/ui/admin/app/router.js b/ui/admin/app/router.js index 1977959ff..785431c8f 100644 --- a/ui/admin/app/router.js +++ b/ui/admin/app/router.js @@ -27,6 +27,9 @@ Router.map(function () { this.route('new'); }); this.route('edit'); + this.route('add-storage-policy', function () { + this.route('create'); + }); this.route('users', function () { this.route('user', { path: ':user_id' }, function () { diff --git a/ui/admin/app/routes/scopes.js b/ui/admin/app/routes/scopes.js index c250cb49f..3368d4aca 100644 --- a/ui/admin/app/routes/scopes.js +++ b/ui/admin/app/routes/scopes.js @@ -6,6 +6,10 @@ import Route from '@ember/routing/route'; import { A } from '@ember/array'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { loading } from 'ember-loading'; +import { confirm } from 'core/decorators/confirm'; +import { notifySuccess, notifyError } from 'core/decorators/notify'; export default class ScopesRoute extends Route { // =services @@ -33,4 +37,19 @@ export default class ScopesRoute extends Route { // expected to default to the global scope, thus returning org scopes. return this.store.findAll('scope').catch(() => A([])); } + + /** + * Deletes the scope and redirects to index. + * @param {Model} scope + */ + @action + @loading + @confirm('resources.policy.questions.detach') + @notifyError(({ message }) => message) + @notifySuccess('resources.policy.messages.detach') + async detachStoragePolicy(scope) { + const { storage_policy_id } = scope; + await scope.detachStoragePolicy(storage_policy_id); + scope.storage_policy_id = ''; + } } diff --git a/ui/admin/app/routes/scopes/scope/add-storage-policy/create.js b/ui/admin/app/routes/scopes/scope/add-storage-policy/create.js new file mode 100644 index 000000000..dbdbe80be --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/add-storage-policy/create.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { TYPE_POLICY } from 'api/models/policy'; +import { action } from '@ember/object'; +import { notifySuccess, notifyError } from 'core/decorators/notify'; +import { loading } from 'ember-loading'; + +export default class ScopesScopeAddStoragePolicyCreateRoute extends Route { + // =services + + @service store; + @service router; + // =methods + + /** + * Creates a new unsaved policy. + * @return {Policy} + */ + model() { + const scopeModel = this.store.peekRecord('scope', 'global'); + const record = this.store.createRecord('policy', { + type: TYPE_POLICY, + }); + record.scopeModel = scopeModel; + return record; + } + + // =actions + + /** + * Handle save + * @param {PolicyModel} policy + */ + @action + @loading + @notifyError(({ message }) => message) + @notifySuccess('notifications.save-success') + async save(policy) { + await policy.save(); + await this.router.transitionTo('scopes.scope.add-storage-policy'); + this.refresh(); + } + + /** + * Rollback changes on policies. + * @param {PolicyModel} policy + */ + @action + cancel(policy) { + policy.rollbackAttributes(); + this.router.transitionTo('scopes.scope.add-storage-policy'); + } +} diff --git a/ui/admin/app/routes/scopes/scope/add-storage-policy/index.js b/ui/admin/app/routes/scopes/scope/add-storage-policy/index.js new file mode 100644 index 000000000..4dcacdcac --- /dev/null +++ b/ui/admin/app/routes/scopes/scope/add-storage-policy/index.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { loading } from 'ember-loading'; +import { notifySuccess, notifyError } from 'core/decorators/notify'; + +export default class ScopesScopeAddStoragePolicyIndexRoute extends Route { + // =services + @service store; + @service router; + + // =methods + + /** + * Load polcies from global and current scope + * @param {Model} model + */ + async afterModel() { + const { id: scope_id } = this.modelFor('scopes.scope'); + + //fetch policies from global scope + const globalScopePolicies = await this.store.query('policy', { + scope_id: 'global', + }); + + //fetch policies from org scope + const orgScopePolicies = await this.store.query('policy', { + scope_id, + }); + + const policyList = [...globalScopePolicies, ...orgScopePolicies]; + + this.policyList = policyList; + } + + /** + * Adds `policyList` to the context. + * @param {Controller} controller + */ + setupController(controller) { + super.setupController(...arguments); + controller.set('policyList', this.policyList); + } + + /** + * Deletes the scope and redirects to index. + * @param {Model} scope + */ + @action + @loading + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.save-success') + async attachStoragePolicy(scope) { + const { storage_policy_id } = scope; + if (storage_policy_id) { + await scope.attachStoragePolicy(storage_policy_id); + } + await scope.save(); + await this.router.transitionTo('scopes.scope.edit', scope); + } + + /** + * Rollback changes on add storage policy. + * @param {PolicyModel} policy + */ + @action + cancel(scope) { + scope.rollbackAttributes(); + this.router.transitionTo('scopes.scope.edit', scope); + } +} diff --git a/ui/admin/app/routes/scopes/scope/edit.js b/ui/admin/app/routes/scopes/scope/edit.js index c4ae2a63f..d6e6661ab 100644 --- a/ui/admin/app/routes/scopes/scope/edit.js +++ b/ui/admin/app/routes/scopes/scope/edit.js @@ -11,7 +11,7 @@ export default class ScopesScopeEditRoute extends Route { @service session; @service router; - + @service store; // =methods /** @@ -20,4 +20,18 @@ export default class ScopesScopeEditRoute extends Route { beforeModel() { if (!this.session.isAuthenticated) this.router.transitionTo('index'); } + + /** + * Adds storage policy name to the context. + * @param {Controller} controller + * @param {PolicyModel} policy + */ + async setupController(controller, policy) { + super.setupController(...arguments); + if (policy.storage_policy_id) { + const { storage_policy_id } = policy; + const record = await this.store.findRecord('policy', storage_policy_id); + controller.set('storage_policy', record); + } + } } diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index b46b85922..676e5df4e 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -985,3 +985,37 @@ } } } + +// policy sidebar + +.policy-sidebar { + background-color: var(--token-form-control-disabled-surface-color); + padding: 1rem; + width: 380px; + + .hds-card__container > ul { + display: flex; + } + + .description { + margin: 0.5rem 0; + } + + .hds-link-standalone__text { + margin: 1rem 0; + } +} + +// add storage policy + +.add-storage-policy { + margin: 1rem 0; +} + +.select-policy { + margin-top: 1rem; +} + +.add-policy-link { + margin-bottom: 1.5rem; +} diff --git a/ui/admin/app/templates/scopes/scope/add-storage-policy/create.hbs b/ui/admin/app/templates/scopes/scope/add-storage-policy/create.hbs new file mode 100644 index 000000000..e5709b1c2 --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/add-storage-policy/create.hbs @@ -0,0 +1,31 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +{{page-title (t 'titles.new')}} + + + + + + + + + + {{t 'resources.policy.titles.new'}} + + + + + + + + \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/add-storage-policy/index.hbs b/ui/admin/app/templates/scopes/scope/add-storage-policy/index.hbs new file mode 100644 index 000000000..5ba74625a --- /dev/null +++ b/ui/admin/app/templates/scopes/scope/add-storage-policy/index.hbs @@ -0,0 +1,31 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +{{page-title (t 'titles.new')}} + + + + + + + + + + {{t 'resources.policy.actions.add'}} + + + + + + + + \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/edit.hbs b/ui/admin/app/templates/scopes/scope/edit.hbs index 9b94bfa99..e92053ac1 100644 --- a/ui/admin/app/templates/scopes/scope/edit.hbs +++ b/ui/admin/app/templates/scopes/scope/edit.hbs @@ -66,21 +66,120 @@ {{/if}} - {{#if (can 'read model' @model)}} - - {{else}} - - - - {{t 'resources.scope.messages.cannot_read'}} - - - - {{/if}} + + + {{#if (can 'read model' @model)}} + + {{else}} + + + + {{t 'resources.scope.messages.cannot_read'}} + + + + {{/if}} + + {{#if (can 'attachStoragePolicy scope' @model)}} + +
+ + {{t 'resources.policy.title'}} + + + {{#if @model.storage_policy_id}} + + {{t 'resources.policy.titles.policy_description'}} + + {{else}} + + {{t 'resources.policy.titles.create_policy_description'}} + + {{/if}} + {{#if @model.storage_policy_id}} + + + {{#if this.storage_policy.retain_for.days}} + + {{t 'resources.policy.titles.retain_for'}} + {{#if (gte this.storage_policy.retain_for.days 1)}} + {{format-day-year this.storage_policy.retain_for.days}} + {{else if (eq this.storage_policy.retain_for.days -1)}} + {{t 'resources.policy.titles.forever'}} + {{/if}} + + {{/if}} + + {{#if (gt this.storage_policy.delete_after.days 0)}} + + {{t 'resources.policy.titles.delete_after'}} + {{format-day-year this.storage_policy.delete_after.days}} + + {{/if}} + + {{#if (can 'detachStoragePolicy scope' @model)}} + + + + + + + + + {{/if}} + + + + + + {{else}} + + {{/if}} +
+
+ {{/if}} +
\ No newline at end of file diff --git a/ui/admin/tests/acceptance/scopes/add-storage-policy-test.js b/ui/admin/tests/acceptance/scopes/add-storage-policy-test.js new file mode 100644 index 000000000..bcfb5f15a --- /dev/null +++ b/ui/admin/tests/acceptance/scopes/add-storage-policy-test.js @@ -0,0 +1,156 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, find, click, currentURL } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import a11yAudit from 'ember-a11y-testing/test-support/audit'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +import select from '@ember/test-helpers/dom/select'; + +module('Acceptance | scope | add storage policy', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let featuresService; + let policyOne; + let policyTwo; + + const SETTINGS_LINK_SELECTOR = '.policy-sidebar .hds-link-standalone'; + const ENABLE_BUTTON_SELECTOR = '.policy-sidebar .hds-button'; + const DROPDOWN_SELECTOR = '[name=policy_id]'; + + const LINK_LIST_SELECTOR_ITEM_TEXT = '.link-list-item__text'; + + const LINK_TO_NEW_STORAGE_POLICY = '.hds-link-standalone'; + const SAVE_BTN_SELECTOR = '.hds-button--color-primary'; + const CANCEL_BTN_SELECTOR = '.hds-button--color-secondary'; + + // Instances + const instances = { + scopes: { + global: null, + org: null, + }, + sessionRecording: null, + }; + + //URLs + const urls = { + globalScope: null, + orgScope: null, + orgScopeEdit: null, + policies: null, + policy: null, + newPolicy: null, + addStoragePolicy: null, + }; + + hooks.beforeEach(function () { + featuresService = this.owner.lookup('service:features'); + // Generate resources + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + + instances.policy = this.server.createList('policy', 2, { + scope: instances.scopes.org, + }); + + policyOne = instances.policy[0]; + policyTwo = instances.policy[1]; + + // Generate route URLs for resources + urls.globalScope = `/scopes/global`; + urls.orgScope = `/scopes/${instances.scopes.org.id}`; + urls.orgScopeEdit = `${urls.orgScope}/edit`; + urls.policies = `${urls.orgScope}/policies`; + urls.policy = `${urls.policies}/${policyOne.id}`; + urls.addStoragePolicy = `${urls.orgScope}/add-storage-policy`; + urls.newPolicy = `${urls.addStoragePolicy}/create`; + authenticateSession({}); + }); + + test('cannot attach policy on a scope without proper authorization', async function (assert) { + assert.false(featuresService.isEnabled('ssh-session-recording')); + await visit(urls.orgScopeEdit); + await a11yAudit(); + assert.dom('.policy-sidebar a').doesNotExist(); + }); + + test('users can click on add storage policy button in the sidebar and it takes them to add a policy', async function (assert) { + featuresService.enable('ssh-session-recording'); + await visit(urls.orgScopeEdit); + assert.dom(SETTINGS_LINK_SELECTOR).doesNotExist(); + await click(ENABLE_BUTTON_SELECTOR); + assert.strictEqual(currentURL(), urls.addStoragePolicy); + }); + + test('users can click on settings link in the sidebar and it takes them to enable session recording', async function (assert) { + featuresService.enable('ssh-session-recording'); + instances.scopes.org.update({ + storagePolicyId: policyOne.id, + }); + await visit(urls.orgScopeEdit); + await click(SETTINGS_LINK_SELECTOR); + assert.strictEqual(currentURL(), urls.addStoragePolicy); + }); + + test('link to add new storage policy should be displayed and redirect to new storage policy form', async function (assert) { + featuresService.enable('ssh-session-recording'); + await visit(urls.orgScopeEdit); + await click(ENABLE_BUTTON_SELECTOR); + assert.strictEqual(currentURL(), urls.addStoragePolicy); + assert.dom(LINK_TO_NEW_STORAGE_POLICY).isVisible(); + await click(LINK_TO_NEW_STORAGE_POLICY); + assert.strictEqual(currentURL(), urls.newPolicy); + }); + + test('can assign a storage policy for the scope', async function (assert) { + featuresService.enable('ssh-session-recording'); + await visit(urls.orgScopeEdit); + await click(ENABLE_BUTTON_SELECTOR); + + assert.strictEqual(currentURL(), urls.addStoragePolicy); + + assert.dom(DROPDOWN_SELECTOR).hasNoValue(); + await select(DROPDOWN_SELECTOR, policyTwo.id); + assert.dom(DROPDOWN_SELECTOR).hasValue(); + await click(SAVE_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.orgScopeEdit); + + assert.strictEqual( + find(LINK_LIST_SELECTOR_ITEM_TEXT).textContent.trim(), + policyTwo.name, + ); + }); + + test('can cancel changes to an existing storage policy selection', async function (assert) { + featuresService.enable('ssh-session-recording'); + instances.scopes.org.update({ + storagePolicyId: policyOne.id, + }); + + await visit(urls.orgScopeEdit); + await click(SETTINGS_LINK_SELECTOR); + assert.strictEqual(currentURL(), urls.addStoragePolicy); + + //update the storage policy selection + await select(DROPDOWN_SELECTOR, policyTwo); + + await click(CANCEL_BTN_SELECTOR); + assert.strictEqual(currentURL(), urls.orgScopeEdit); + + assert.strictEqual( + find(LINK_LIST_SELECTOR_ITEM_TEXT).textContent.trim(), + policyOne.name, + ); + }); +}); diff --git a/ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/create-test.js b/ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/create-test.js new file mode 100644 index 000000000..493e5e7e4 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/create-test.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module( + 'Unit | Route | scopes/scope/add-storage-policy/create', + function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/add-storage-policy/create', + ); + assert.ok(route); + }); + }, +); diff --git a/ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/index-test.js b/ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/index-test.js new file mode 100644 index 000000000..85fb21e25 --- /dev/null +++ b/ui/admin/tests/unit/routes/scopes/scope/add-storage-policy/index-test.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'admin/tests/helpers'; + +module('Unit | Route | scopes/scope/add-storage-policy', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup( + 'route:scopes/scope/add-storage-policy/index', + ); + assert.ok(route); + }); +});