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');
+ });
+});