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