policy association form (#2089)

* feat: 🎸 add storage policy api and mirage
This commit is contained in:
Dharini Jeeva authored and GitHub committed 2024-01-12 15:01:16 -08:00
1 parent ee4e19ace7
commit ba786d2172
16 files changed
+688 -17

No files matched your search

+22
View File
@@ -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(
@@ -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.
@@ -0,0 +1,62 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
<Rose::Form
class='full-width'
@onSubmit={{@submit}}
@cancel={{@cancel}}
as |form|
>
<Hds::Form::Select::Field
class='select-policy'
@isRequired={{true}}
@isInvalid={{@model.errors.storage_policy_id}}
@value={{@model.storage_policy_id}}
@type='text'
@width='30%'
name='policy_id'
{{on 'change' this.selectPolicy}}
as |F|
>
<F.Label>{{t 'resources.policy.title'}}</F.Label>
<F.HelperText>
{{t 'resources.policy.actions.apply'}}
</F.HelperText>
<F.Options>
<option disabled hidden selected value=''>{{t
'titles.choose-an-option'
}}</option>
{{#each @policyList as |policy|}}
<option
value={{policy.id}}
selected={{eq @model.storage_policy_id policy.id}}
>
{{policy.displayName}}
</option>
{{/each}}
</F.Options>
{{#if @model.errors.storage_policy_id}}
<F.Error as |E|>
{{#each @model.errors.storage_policy_id as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Select::Field>
<Hds::Link::Standalone
class='add-policy-link'
@color='primary'
@icon='plus'
@text={{t 'resources.policy.actions.new'}}
@route='scopes.scope.add-storage-policy.create'
/>
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
</Rose::Form>
@@ -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;
}
}
+3
View File
@@ -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 () {
+19
View File
@@ -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 = '';
}
}
@@ -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');
}
}
@@ -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);
}
}
+15 -1
View File
@@ -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);
}
}
}
+34
View File
@@ -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;
}
@@ -0,0 +1,31 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
{{page-title (t 'titles.new')}}
<Breadcrumbs::Item
@text={{t 'resources.policy.titles.new'}}
@route='scopes.scope.add-storage-policy.create'
/>
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<Hds::Text::Display @tag='h2'>
{{t 'resources.policy.titles.new'}}
</Hds::Text::Display>
</page.header>
<page.body>
<Form::Policy
@model={{@model}}
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
/>
</page.body>
</Rose::Layout::Page>
@@ -0,0 +1,31 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
{{page-title (t 'titles.new')}}
<Breadcrumbs::Item
@text={{t 'resources.policy.actions.add'}}
@route='scopes.scope.add-storage-policy'
/>
<Rose::Layout::Page as |page|>
<page.breadcrumbs>
<Breadcrumbs::Container />
</page.breadcrumbs>
<page.header>
<Hds::Text::Display @tag='h1' class='add-storage-policy'>
{{t 'resources.policy.actions.add'}}
</Hds::Text::Display>
</page.header>
<page.body>
<Form::Scope::AddStoragePolicy
@model={{@model}}
@policyList={{this.policyList}}
@submit={{route-action 'attachStoragePolicy' @model}}
@cancel={{route-action 'cancel' @model}}
/>
</page.body>
</Rose::Layout::Page>
+114 -15
View File
@@ -66,21 +66,120 @@
{{/if}}
<page.body>
{{#if (can 'read model' @model)}}
<Form::Scope
@model={{@model}}
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
/>
{{else}}
<Rose::Layout::Centered>
<Rose::Message as |message|>
<message.description>
{{t 'resources.scope.messages.cannot_read'}}
</message.description>
</Rose::Message>
</Rose::Layout::Centered>
{{/if}}
<Rose::Layout::BodyContent as |bc|>
<bc.Body>
{{#if (can 'read model' @model)}}
<Form::Scope
@model={{@model}}
@submit={{route-action 'save' @model}}
@cancel={{route-action 'cancel' @model}}
/>
{{else}}
<Rose::Layout::Centered>
<Rose::Message as |message|>
<message.description>
{{t 'resources.scope.messages.cannot_read'}}
</message.description>
</Rose::Message>
</Rose::Layout::Centered>
{{/if}}
</bc.Body>
{{#if (can 'attachStoragePolicy scope' @model)}}
<bc.Sidebar>
<div class='policy-sidebar'>
<Hds::Text::Display @tag='h3'>
{{t 'resources.policy.title'}}
</Hds::Text::Display>
{{#if @model.storage_policy_id}}
<Hds::Text::Body @tag='p' class='description'>
{{t 'resources.policy.titles.policy_description'}}
</Hds::Text::Body>
{{else}}
<Hds::Text::Body @tag='p' class='description'>
{{t 'resources.policy.titles.create_policy_description'}}
</Hds::Text::Body>
{{/if}}
{{#if @model.storage_policy_id}}
<LinkListPanel as |P|>
<P.Item
@icon='hourglass'
@color='boundary'
@text={{this.storage_policy.name}}
>
{{#if this.storage_policy.retain_for.days}}
<Hds::Text::Body
@tag='p'
@color='var(--ui-gray-subtler-1)'
@size='100'
>
{{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}}
</Hds::Text::Body>
{{/if}}
{{#if (gt this.storage_policy.delete_after.days 0)}}
<Hds::Text::Body
@tag='p'
@color='var(--ui-gray-subtler-1)'
@size='100'
>
{{t 'resources.policy.titles.delete_after'}}
{{format-day-year this.storage_policy.delete_after.days}}
</Hds::Text::Body>
{{/if}}
</P.Item>
{{#if (can 'detachStoragePolicy scope' @model)}}
<P.Item>
<Hds::Dropdown as |dd|>
<dd.ToggleIcon
@icon='more-horizontal'
@text={{t 'actions.manage'}}
@hasChevron={{false}}
@size='small'
/>
<dd.Interactive
@route='scopes.scope.policies.policy'
@model={{this.storage_policy.id}}
@text={{t 'actions.view'}}
/>
<dd.Separator />
<dd.Interactive
@text={{t 'resources.policy.actions.delete'}}
@color='critical'
{{on
'click'
(route-action 'detachStoragePolicy' @model)
}}
/>
</Hds::Dropdown>
</P.Item>
{{/if}}
</LinkListPanel>
<Hds::Link::Standalone
@icon='arrow-right'
@iconPosition='trailing'
@text={{t 'resources.policy.actions.add'}}
@route='scopes.scope.add-storage-policy'
/>
{{else}}
<Hds::Button
@text={{t 'resources.policy.actions.add'}}
@color='secondary'
@route='scopes.scope.add-storage-policy'
/>
{{/if}}
</div>
</bc.Sidebar>
{{/if}}
</Rose::Layout::BodyContent>
</page.body>
</Rose::Layout::Page>
@@ -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,
);
});
});
@@ -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);
});
},
);
@@ -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);
});
});