diff --git a/addons/api/addon/transforms/object.js b/addons/api/addon/transforms/object.js index 98f4732c6..d3dc3bbab 100644 --- a/addons/api/addon/transforms/object.js +++ b/addons/api/addon/transforms/object.js @@ -22,7 +22,7 @@ export default class ObjectTransform extends Transform { */ serialize(deserialized) { if (!deserialized) { - return null; + return {}; } return deserialized; } diff --git a/addons/api/mirage/serializers/policy.js b/addons/api/mirage/serializers/policy.js new file mode 100644 index 000000000..26aa60d4a --- /dev/null +++ b/addons/api/mirage/serializers/policy.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + modelName: 'policy', +}); diff --git a/addons/core/addon/helpers/format-day-year.js b/addons/core/addon/helpers/format-day-year.js index 352f8a2ec..7764dab27 100644 --- a/addons/core/addon/helpers/format-day-year.js +++ b/addons/core/addon/helpers/format-day-year.js @@ -24,12 +24,14 @@ export default class extends Helper { */ compute([days]) { - const remainder = days % 365; - if (remainder === 0) { - const numberOfYears = days / 365; - return this.intl.t('titles.year', { numberOfYears }); - } else { - return this.intl.t('titles.days', { days }); + if (days > 0) { + const remainder = days % 365; + if (remainder === 0) { + const numberOfYears = days / 365; + return this.intl.t('titles.year', { numberOfYears }); + } else { + return this.intl.t('titles.days', { days }); + } } } } diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 90aac18f9..d79c5958f 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -925,6 +925,7 @@ policy: delete_after: Delete After do_not_delete: Do not delete forever: Forever + days: days actions: delete: Delete Storage Policy messages: @@ -932,3 +933,24 @@ policy: 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. link: Create a new storage policy + form: + retention_policy: + label: Retention Policy + help: Specifies how long a recording must be stored. + deletion_policy: + label: Deletion Policy + help: Specifies when to delete a recording + overridable: + label: Allow orgs to override + retention_policy_options: + forever: + label: Forever + custom: + label: Custom + do_not_protect: + label: Do not protect, allow deletion any time + deletion_policy_options: + do_not_delete: + label: Do not delete + custom: + label: Custom diff --git a/ui/admin/app/components/form/policy/index.hbs b/ui/admin/app/components/form/policy/index.hbs new file mode 100644 index 000000000..80ce173ac --- /dev/null +++ b/ui/admin/app/components/form/policy/index.hbs @@ -0,0 +1,81 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + {{t 'form.name.label'}} + {{t 'form.name.help'}} + {{#if @model.errors.name}} + + {{#each @model.errors.name as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{t 'form.description.label'}} + {{t 'form.description.help'}} + {{#if @model.errors.description}} + + {{#each @model.errors.description as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{! retention policy }} + + + {{! deletion policy }} + + + {{#if (can 'save model' @model)}} + + {{/if}} + \ No newline at end of file diff --git a/ui/admin/app/components/form/policy/index.js b/ui/admin/app/components/form/policy/index.js new file mode 100644 index 000000000..d1841e7a4 --- /dev/null +++ b/ui/admin/app/components/form/policy/index.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; + +const RETENTION_POLICY = { + forever: -1, + custom: 1, + do_not_protect: 0, +}; + +const DELETION_POLICY = { do_not_delete: 0, custom: 1 }; + +export default class FormPolicyComponent extends Component { + //attributes + + //methods + + /** + * Returns retention policy options list + * @type {array} + */ + get listRententionOptions() { + return RETENTION_POLICY; + } + + /** + * Returns deletion policy options list, if the retain days are -1 (forever) + * then we should force the delete option to be do not delete + * @type {array} + */ + get listDeletionOptions() { + if (this.args.model.retain_for?.days < 0) { + return { do_not_delete: 0 }; + } else { + return DELETION_POLICY; + } + } + + /** + * Returns policy type + * @type {string} + */ + get selectRetentionPolicyType() { + if (this.args.model.retain_for?.days < 0) { + return 'forever'; + } else if (this.args.model.retain_for?.days >= 1) { + return 'custom'; + } else { + return 'do_not_protect'; + } + } + + /** + * Returns policy type + * @type {string} + */ + get selectDeletePolicyType() { + return this.args.model.delete_after?.days > 0 ? 'custom' : 'do_not_delete'; + } +} diff --git a/ui/admin/app/components/form/policy/policy-selection/index.hbs b/ui/admin/app/components/form/policy/policy-selection/index.hbs new file mode 100644 index 000000000..85aef453a --- /dev/null +++ b/ui/admin/app/components/form/policy/policy-selection/index.hbs @@ -0,0 +1,71 @@ + + + {{t (concat 'resources.policy.form.' @name '.label')}} + + {{t (concat 'resources.policy.form.' @name '.help')}} + + + + + + + {{#each-in @options as |key value|}} + + {{/each-in}} + + + + + + +{{#if this.showCustomInput}} + + {{t (concat 'resources.policy.titles.' @customInputName)}} + + + + + + + + + {{#if @model.scope.isGlobal}} + + {{t 'resources.policy.form.overridable.label'}} + + {{/if}} + + + +{{/if}} \ No newline at end of file diff --git a/ui/admin/app/components/form/policy/policy-selection/index.js b/ui/admin/app/components/form/policy/policy-selection/index.js new file mode 100644 index 000000000..1afe83f9e --- /dev/null +++ b/ui/admin/app/components/form/policy/policy-selection/index.js @@ -0,0 +1,109 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class FormPolicySelectionComponent extends Component { + //methods + /** + * This is used to show/hide the custom input field + * @type {boolean} + */ + get showCustomInput() { + if (this.args.name === 'retention_policy') { + return this.isCustomRetentionSelected; + } else if ( + this.args.name === 'deletion_policy' && + this.args.model.retain_for?.days >= 0 + ) { + return this.isCustomDeletionSelected; + } else { + return null; + } + } + + /** + * Returns true if rentention days are greater than 0 + * @type {boolean} + */ + get isCustomRetentionSelected() { + return this.args.model.retain_for?.days > 0; + } + + /** + * Returns true if deletion days are greater than 0 + * @type {boolean} + */ + get isCustomDeletionSelected() { + return this.args.model.delete_after?.days > 0; + } + /** + * Returns true if the toggle is on + * @type {boolean} + */ + get isOverridable() { + return this.args.model[this.args.customInputName]?.overridable; + } + + get isDeleteDisable() { + if ( + this.args.name === 'deletion_policy' && + this.args.model.retain_for?.days === -1 + ) { + return true; + } else { + return false; + } + } + + //actions + /** + * Handles custom input changes + */ + @action + handleInputChange({ target: { value, name: field } }) { + // Select options return type is string and we want the `days` in integer format + const val = Number(value); + this.args.model[field] = { + ...this.args.model[field], + days: val, + }; + } + + /** + * Toggle retain for & delete after overridables + */ + @action + handleOverridableToggle({ target: { name: field } }) { + this.args.model[field] = { + ...this.args.model[field], + overridable: !this.args.model[field].overridable, + }; + } + + /** + * Show custom text field when custom option is selected + */ + @action + handlePolicyTypeSelection({ target: { value, name: policy } }) { + //Select options return type is string and we want the `days` in integer format + const selectedVal = Number(value); + if (policy === 'retention_policy') { + this.args.model.retain_for = { + ...this.args.model.retain_for, + days: selectedVal, + }; + if (selectedVal < 0) { + this.args.model.delete_after = { + ...this.args.model.delete_after, + days: 0, + }; + } + } + + if (policy === 'deletion_policy') { + this.args.model.delete_after = { + ...this.args.model.delete_after, + days: selectedVal, + }; + } + } +} diff --git a/ui/admin/app/routes/scopes/scope/policies.js b/ui/admin/app/routes/scopes/scope/policies.js index c8fcbeddb..acdffcfe6 100644 --- a/ui/admin/app/routes/scopes/scope/policies.js +++ b/ui/admin/app/routes/scopes/scope/policies.js @@ -14,8 +14,9 @@ export default class ScopesScopePoliciesRoute extends Route { // =services @service store; - @service can; @service session; + @service can; + @service router; // =methods @@ -35,7 +36,7 @@ export default class ScopesScopePoliciesRoute extends Route { const { id: scope_id } = scope; if ( - this.can.can('list scope', scope, { + this.can.can('list model', scope, { collection: 'policies', }) ) { @@ -43,6 +44,33 @@ export default class ScopesScopePoliciesRoute extends Route { } } + //actions + + /** + * Rollback changes on a policy. + * @param {PolicyModel} policy + */ + @action + cancel(policy) { + const { isNew } = policy; + policy.rollbackAttributes(); + if (isNew) this.router.transitionTo('scopes.scope.policies'); + } + + /** + * Save a policy in current scope. + * @param {PolicyModel} policy + */ + @action + @loading + @notifyError(({ message }) => message) + @notifySuccess('notifications.save-success') + async save(policy) { + await policy.save(); + await this.router.transitionTo('scopes.scope.policies.policy', policy); + + this.refresh(); + } /** * Deletes the policy. * @param {PolicyModel} policy diff --git a/ui/admin/app/routes/scopes/scope/policies/new.js b/ui/admin/app/routes/scopes/scope/policies/new.js index dd76d3a87..88175fb4e 100644 --- a/ui/admin/app/routes/scopes/scope/policies/new.js +++ b/ui/admin/app/routes/scopes/scope/policies/new.js @@ -4,7 +4,39 @@ */ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; export default class ScopesScopePoliciesNewRoute extends Route { // =services + + @service store; + @service can; + @service router; + + // =methods + + /** + * Redirect to parent route when scope does not have create authorized action. + */ + beforeModel() { + const scopeModel = this.modelFor('scopes.scope'); + if ( + this.can.cannot('create model', scopeModel, { collection: 'policies' }) + ) { + this.router.replaceWith('scopes.scope.policies'); + } + } + + /** + * Creates a new unsaved policy. + * @return {PolicyModel} + */ + model() { + const scopeModel = this.modelFor('scopes.scope'); + const record = this.store.createRecord('policy', { + type: 'storage', + }); + record.scopeModel = scopeModel; + return record; + } } diff --git a/ui/admin/app/routes/scopes/scope/policies/policy.js b/ui/admin/app/routes/scopes/scope/policies/policy.js index d5dd768fe..9548793c4 100644 --- a/ui/admin/app/routes/scopes/scope/policies/policy.js +++ b/ui/admin/app/routes/scopes/scope/policies/policy.js @@ -9,7 +9,6 @@ import { inject as service } from '@ember/service'; export default class ScopesScopePoliciesPolicyRoute extends Route { @service store; @service can; - @service router; // =methods diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index 3dd3daa5d..b46b85922 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -951,3 +951,37 @@ margin-bottom: 1.5rem; } } + +// storage policies +.policy { + margin-bottom: 0; +} + +.policy-custom-input { + .hds-form-field__control { + display: flex; + justify-content: space-between; + align-items: center; + + .hds-segmented-group { + width: 45%; + } + + .hds-form-field--layout-flag { + width: 40%; + margin-bottom: 0; + } + } + + .hds-badge { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-color: var(--ui-gray-subtler-1); + border-left: 0; + + &.disabled { + color: var(--token-form-control-disabled-foreground-color); + border-color: var(--token-form-control-disabled-border-color); + } + } +} diff --git a/ui/admin/app/templates/scopes/scope/policies/index.hbs b/ui/admin/app/templates/scopes/scope/policies/index.hbs index ce799b60b..3b1ddbb69 100644 --- a/ui/admin/app/templates/scopes/scope/policies/index.hbs +++ b/ui/admin/app/templates/scopes/scope/policies/index.hbs @@ -64,17 +64,16 @@ {{/if}} - {{#if B.data.retain_for.days}} + {{! leave blank space when the retain days are 0 }} + {{#if (gte B.data.retain_for.days 1)}} {{format-day-year B.data.retain_for.days}} - {{else}} + {{else if (eq B.data.retain_for.days -1)}} {{t 'resources.policy.titles.forever'}} {{/if}} {{#if B.data.delete_after.days}} {{format-day-year B.data.delete_after.days}} - {{else}} - {{t 'resources.policy.titles.do_not_delete'}} {{/if}} diff --git a/ui/admin/app/templates/scopes/scope/policies/new.hbs b/ui/admin/app/templates/scopes/scope/policies/new.hbs index 03eb23195..2a8c26728 100644 --- a/ui/admin/app/templates/scopes/scope/policies/new.hbs +++ b/ui/admin/app/templates/scopes/scope/policies/new.hbs @@ -7,4 +7,25 @@ \ No newline at end of file +/> + + + + + + + +

+ {{t 'resources.policy.titles.new'}} +

+
+ + + + + +
\ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/policies/policy/index.hbs b/ui/admin/app/templates/scopes/scope/policies/policy/index.hbs index 3faf92ead..93ba16f46 100644 --- a/ui/admin/app/templates/scopes/scope/policies/policy/index.hbs +++ b/ui/admin/app/templates/scopes/scope/policies/policy/index.hbs @@ -1,4 +1,39 @@ {{! Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: MPL-2.0 -}} \ No newline at end of file +}} + + + + + + + +

+ {{t 'resources.policy.title'}} +

+ + {{@model.id}} + +
+ + + + + {{t 'titles.details'}} + + + + + + + +
\ No newline at end of file diff --git a/ui/admin/tests/acceptance/policy/create-test.js b/ui/admin/tests/acceptance/policy/create-test.js new file mode 100644 index 000000000..dee3803c7 --- /dev/null +++ b/ui/admin/tests/acceptance/policy/create-test.js @@ -0,0 +1,135 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, currentURL, click, fillIn } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import a11yAudit from 'ember-a11y-testing/test-support/audit'; +import { Response } from 'miragejs'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | policies | create', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let features; + let getPolicyCount; + + const SAVE_BTN_SELECTOR = '[type="submit"]'; + const CANCEL_BTN_SELECTOR = '.rose-form-actions [type="button"]'; + const NAME_FIELD_SELECTOR = '[name="name"]'; + const RETAIN_FOR_INPUT = 'input[data-input-type="retain_for"]'; + const ALERT_TEXT_SELECTOR = '[role="alert"] div'; + const FIELD_ERROR_TEXT_SELECTOR = '.hds-form-error__message'; + const NAME_FIELD_TEXT = 'random string'; + + const instances = { + scopes: { + global: null, + org: null, + }, + }; + + const urls = { + globalScope: null, + policies: null, + newPolicy: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + instances.scopes.org = this.server.create('scope', { + type: 'org', + scope: { id: 'global', type: 'global' }, + }); + urls.globalScope = `/scopes/global`; + urls.policies = `${urls.globalScope}/policies`; + urls.newPolicy = `${urls.policies}/new`; + getPolicyCount = () => this.server.schema.policies.all().models.length; + features = this.owner.lookup('service:features'); + features.enable('ssh-session-recording'); + authenticateSession({}); + }); + + test('users can create a new policy with global scope', async function (assert) { + const policyCount = getPolicyCount(); + await visit(urls.policies); + + await click(`[href="${urls.newPolicy}"]`); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + + await click('.hds-form-select'); + await fillIn('.hds-form-select', 1); + await fillIn(RETAIN_FOR_INPUT, 90); + await click(SAVE_BTN_SELECTOR); + const policy = this.server.schema.policies.findBy({ + name: NAME_FIELD_TEXT, + }); + + assert.strictEqual(policy.name, NAME_FIELD_TEXT); + assert.strictEqual(policy.scopeId, 'global'); + assert.strictEqual(policy.attributes.retain_for.days, 90); + assert.strictEqual(getPolicyCount(), policyCount + 1); + }); + + test('user can cancel new policy creation', async function (assert) { + const policyCount = getPolicyCount(); + await visit(urls.policies); + + await click(`[href="${urls.newPolicy}"]`); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.policies); + assert.strictEqual(getPolicyCount(), policyCount); + }); + + test('saving a new policy with invalid fields displays error messages', async function (assert) { + this.server.post('/policies', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: 'The request was invalid.', + details: { + request_fields: [ + { + name: 'name', + description: 'Name is required.', + }, + ], + }, + }, + ); + }); + await visit(urls.policies); + + await click(`[href="${urls.newPolicy}"]`); + await click(SAVE_BTN_SELECTOR); + await a11yAudit(); + + assert.dom(ALERT_TEXT_SELECTOR).hasText('The request was invalid.'); + assert.dom(FIELD_ERROR_TEXT_SELECTOR).hasText('Name is required.'); + }); + + test('users cannot directly navigate to new policy route without proper authorization', async function (assert) { + instances.scopes.global.authorized_collection_actions['policies'] = + instances.scopes.global.authorized_collection_actions['policies'].filter( + (item) => item !== 'create', + ); + + await visit(urls.newPolicy); + + assert.false( + instances.scopes.global.authorized_collection_actions[ + 'policies' + ].includes('create'), + ); + assert.strictEqual(currentURL(), urls.policies); + }); +}); diff --git a/ui/admin/tests/acceptance/policy/read-test.js b/ui/admin/tests/acceptance/policy/read-test.js new file mode 100644 index 000000000..b888496c9 --- /dev/null +++ b/ui/admin/tests/acceptance/policy/read-test.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, currentURL, click } from '@ember/test-helpers'; +import { setupApplicationTest } from 'admin/tests/helpers'; +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'; + +module('Acceptance | policies | read', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let features; + + const MESSAGE_SELECTOR = '.rose-message-subtitle'; + const TABLE_LINK_SELECTOR = '.hds-table__tbody tr:first-child a'; + + const instances = { + scopes: { + global: null, + org: null, + }, + policy: null, + }; + + const urls = { + globalScope: null, + policies: null, + policy: null, + unknownPolicy: null, + }; + + hooks.beforeEach(function () { + 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.create('policy', { + scope: instances.scopes.global, + }); + urls.globalScope = `/scopes/global`; + urls.policies = `${urls.globalScope}/policies`; + urls.policy = `${urls.policies}/${instances.policy.id}`; + urls.unknownPolicy = `${urls.policies}/foo`; + + features = this.owner.lookup('service:features'); + features.enable('ssh-session-recording'); + authenticateSession({}); + }); + + test('visiting a policy', async function (assert) { + await visit(urls.globalScope); + await a11yAudit(); + + await click(`[href="${urls.policies}"]`); + await a11yAudit(); + await click(`[href="${urls.policy}"]`); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.policy); + }); + + test('cannot navigate to a policy without proper authorization', async function (assert) { + await visit(urls.globalScope); + instances.policy.authorized_actions = + instances.policy.authorized_actions.filter((item) => item !== 'read'); + + await click(`[href="${urls.policies}"]`); + + assert.dom(TABLE_LINK_SELECTOR).doesNotExist(); + }); + + test('visiting an unknown policy displays 404 message', async function (assert) { + await visit(urls.unknownPolicy); + await a11yAudit(); + + assert.dom(MESSAGE_SELECTOR).hasText('Error 404'); + }); +});