diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 4396ac2dc..0fd5949a4 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -290,6 +290,10 @@ storage-bucket: worker_filter: label: Worker filter help: Filter for Boundary workers that have network access to your storage. Boolean expression. + questions: + delete-storage-bucket: + title: Delete associated recordings? + message: This storage bucket may have session recordings associated with it. Deleting this storage bucket will delete these recordings when you save your changes. project: title: Project title_plural: Projects diff --git a/ui/admin/app/routes/scopes/scope/storage-buckets.js b/ui/admin/app/routes/scopes/scope/storage-buckets.js index 8185c65f8..eee836051 100644 --- a/ui/admin/app/routes/scopes/scope/storage-buckets.js +++ b/ui/admin/app/routes/scopes/scope/storage-buckets.js @@ -7,6 +7,7 @@ import Route from '@ember/routing/route'; 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 ScopesScopeStorageBucketsRoute extends Route { @@ -71,4 +72,20 @@ export default class ScopesScopeStorageBucketsRoute extends Route { storageBucket.rollbackAttributes(); if (isNew) this.router.transitionTo('scopes.scope.storage-buckets'); } + + /** + * Deletes the storage bucket. + * @param {StorageBucketModel} storageBucket + */ + @action + @loading + @confirm('resources.storage-bucket.questions.delete-storage-bucket.message', { + title: 'resources.storage-bucket.questions.delete-storage-bucket.title', + confirm: 'resources.storage-bucket.actions.delete', + }) + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.delete-success') + async delete(storageBucket) { + await storageBucket.destroyRecord(); + } } diff --git a/ui/admin/app/templates/scopes/scope/storage-buckets/index.hbs b/ui/admin/app/templates/scopes/scope/storage-buckets/index.hbs index f29ed6f90..4b3f46253 100644 --- a/ui/admin/app/templates/scopes/scope/storage-buckets/index.hbs +++ b/ui/admin/app/templates/scopes/scope/storage-buckets/index.hbs @@ -102,6 +102,14 @@ @model={{B.data.id}} @text={{t 'actions.edit'}} /> + {{#if (can 'delete model' B.data)}} + + + {{/if}} diff --git a/ui/admin/tests/acceptance/storage-buckets/delete-test.js b/ui/admin/tests/acceptance/storage-buckets/delete-test.js new file mode 100644 index 000000000..fac18ebe0 --- /dev/null +++ b/ui/admin/tests/acceptance/storage-buckets/delete-test.js @@ -0,0 +1,163 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, click, currentURL } from '@ember/test-helpers'; +import { setupApplicationTest } from 'ember-qunit'; +import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; +import { Response } from 'miragejs'; +import { authenticateSession } from 'ember-simple-auth/test-support'; + +module('Acceptance | storage-buckets | delete', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let intl; + let features; + let getStorageBucketCount; + + const DROPDOWN_BUTTON_SELECTOR = '.hds-dropdown-toggle-icon'; + const DELETE_DROPDOWN_SELECTOR = '.hds-dropdown-list [type="button"]'; + const DIALOG_DELETE_BTN_SELECTOR = '.rose-dialog .rose-button-primary'; + const DIALOG_CANCEL_BTN_SELECTOR = '.rose-dialog .rose-button-secondary'; + const DIALOG_MESSAGE_SELECTOR = '.rose-dialog-body'; + const DIALOG_TITLE_SELECTOR = '.rose-dialog-header'; + const NOTIFICATION_MSG_SELECTOR = '.rose-notification-body'; + const NOTIFICATION_MSG_TEXT = 'Deleted successfully.'; + + const instances = { + scopes: { + global: null, + org: null, + }, + storageBucket: null, + }; + + const urls = { + globalScope: null, + storageBuckets: 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.storageBucket = this.server.create('storage-bucket', { + scope: instances.scopes.global, + }); + urls.globalScope = `/scopes/global`; + urls.storageBuckets = `${urls.globalScope}/storage-buckets`; + getStorageBucketCount = () => + this.server.schema.storageBuckets.all().models.length; + + authenticateSession({}); + intl = this.owner.lookup('service:intl'); + features = this.owner.lookup('service:features'); + features.enable('session-recording'); + }); + + test('user can delete a storage bucket', async function (assert) { + assert.expect(2); + const storageBucketCount = getStorageBucketCount(); + await visit(urls.globalScope); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert.strictEqual(currentURL(), urls.storageBuckets); + assert.strictEqual(getStorageBucketCount(), storageBucketCount - 1); + }); + + test('user can accept delete storage bucket via dialog', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const storageBucketCount = getStorageBucketCount(); + await visit(urls.globalScope); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert + .dom(DIALOG_DELETE_BTN_SELECTOR) + .hasText(intl.t('resources.storage-bucket.actions.delete')); + + await click(DIALOG_DELETE_BTN_SELECTOR); + + assert.dom(NOTIFICATION_MSG_SELECTOR).hasText(NOTIFICATION_MSG_TEXT); + assert.strictEqual(currentURL(), urls.storageBuckets); + assert.strictEqual(getStorageBucketCount(), storageBucketCount - 1); + }); + + test('user can cancel delete storage bucket via dialog', async function (assert) { + assert.expect(4); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const storageBucketCount = getStorageBucketCount(); + await visit(urls.globalScope); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert + .dom(DIALOG_TITLE_SELECTOR) + .hasText( + intl.t('resources.storage-bucket.questions.delete-storage-bucket.title') + ); + assert + .dom(DIALOG_MESSAGE_SELECTOR) + .hasText( + intl.t( + 'resources.storage-bucket.questions.delete-storage-bucket.message' + ) + ); + + await click(DIALOG_CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.storageBuckets); + assert.strictEqual(getStorageBucketCount(), storageBucketCount); + }); + + test('user cannot delete storage bucket without proper authorization', async function (assert) { + assert.expect(1); + await visit(urls.globalScope); + instances.storageBucket.authorized_actions = + instances.storageBucket.authorized_actions.filter( + (item) => item !== 'delete' + ); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + + assert.dom(DELETE_DROPDOWN_SELECTOR).doesNotExist(); + }); + + test('deleting a storage bucket which errors displays error messages', async function (assert) { + assert.expect(1); + await visit(urls.globalScope); + this.server.del('/storage-buckets/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + } + ); + }); + + await click(`[href="${urls.storageBuckets}"]`); + await click(DROPDOWN_BUTTON_SELECTOR); + await click(DELETE_DROPDOWN_SELECTOR); + + assert.dom(NOTIFICATION_MSG_SELECTOR).hasText('Oops.'); + }); +});