From 2dbcc1c0afdcd1c92f77c2f87c682df71db5018b Mon Sep 17 00:00:00 2001 From: Dharini Jeeva Date: Thu, 21 Mar 2024 09:50:51 -0700 Subject: [PATCH] Alias create (#2193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 create alias --- addons/api/addon/generated/models/alias.js | 6 + addons/api/mirage/serializers/alias.js | 10 ++ addons/core/translations/resources/en-us.yaml | 21 +++ ui/admin/app/components/form/alias/index.hbs | 152 ++++++++++++++++++ ui/admin/app/components/form/alias/index.js | 22 +++ .../controllers/scopes/scope/aliases/index.js | 54 +++++++ .../templates/scopes/scope/aliases/index.hbs | 34 +++- .../templates/scopes/scope/aliases/new.hbs | 8 +- .../tests/acceptance/aliases/create-test.js | 149 +++++++++++++++++ 9 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 addons/api/mirage/serializers/alias.js create mode 100644 ui/admin/app/components/form/alias/index.hbs create mode 100644 ui/admin/app/components/form/alias/index.js create mode 100644 ui/admin/tests/acceptance/aliases/create-test.js diff --git a/addons/api/addon/generated/models/alias.js b/addons/api/addon/generated/models/alias.js index 98a8767ee..356795850 100644 --- a/addons/api/addon/generated/models/alias.js +++ b/addons/api/addon/generated/models/alias.js @@ -44,6 +44,12 @@ export default class GeneratedAliasModel extends BaseModel { }) version; + @attr('string', { + description: + 'This is the value referenced by the user that is resolved to the destination id.', + }) + value; + @attr('string', { description: 'This is the id of the resource that this Alias points to.', }) diff --git a/addons/api/mirage/serializers/alias.js b/addons/api/mirage/serializers/alias.js new file mode 100644 index 000000000..c754bc483 --- /dev/null +++ b/addons/api/mirage/serializers/alias.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + modelName: 'alias', +}); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 06deb34bb..6d79c3562 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -1002,5 +1002,26 @@ policy: alias: title: Alias title_plural: Aliases + description: An Alias is a DNS-like string associated with a destination resource. titles: new: New Alias + messages: + none: + title: Welcome to Aliases + description: No Aliases yet + link: Create a new alias + form: + options: + label: Alias options + type: + label: Target + help: Servers, API Endpoints, and network devices + destination_id: + label: Target ID + help: Any resource in this scope or any orgs and projects inside it + value: + label: Alias Value + help: The alias you would like to use for this resource + authorize_session_arguments: + label: Host ID + help: Always use this host when connecting using this alias diff --git a/ui/admin/app/components/form/alias/index.hbs b/ui/admin/app/components/form/alias/index.hbs new file mode 100644 index 000000000..678226eae --- /dev/null +++ b/ui/admin/app/components/form/alias/index.hbs @@ -0,0 +1,152 @@ +{{! + 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}} + + + + + {{t 'resources.alias.form.options.label'}} + + + {{t 'resources.alias.description'}} + + + + {{t 'resources.target.form.type.label'}} + + {{t 'resources.alias.form.type.label'}} + {{t 'resources.alias.form.type.help'}} + + + + + + + + {{t 'resources.alias.form.destination_id.label'}} + {{t + 'resources.alias.form.destination_id.help' + }} + {{#if @model.errors.destination_id}} + + {{#each @model.errors.destination_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t + 'resources.alias.form.authorize_session_arguments.label' + }} + {{t + 'resources.alias.form.authorize_session_arguments.help' + }} + {{#if @model.errors.authorize_session_arguments.host_id}} + + {{#each @model.errors.authorize_session_arguments.host_id as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + + {{t 'resources.scope.title'}} + + + + {{t 'resources.alias.form.value.label'}} + {{t 'resources.alias.form.value.help'}} + {{#if @model.errors.value}} + + {{#each @model.errors.value as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if (can 'save model' @model)}} + + {{/if}} + \ No newline at end of file diff --git a/ui/admin/app/components/form/alias/index.js b/ui/admin/app/components/form/alias/index.js new file mode 100644 index 000000000..6c96f455b --- /dev/null +++ b/ui/admin/app/components/form/alias/index.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class FormAliasComponent extends Component { + //actions + /** + * Handles input changes + */ + @action + handleHostIdChange({ target: { value } }) { + if (value) { + this.args.model.authorize_session_arguments = { + host_id: value, + }; + } + } +} diff --git a/ui/admin/app/controllers/scopes/scope/aliases/index.js b/ui/admin/app/controllers/scopes/scope/aliases/index.js index a2da2cb93..7741c9f0b 100644 --- a/ui/admin/app/controllers/scopes/scope/aliases/index.js +++ b/ui/admin/app/controllers/scopes/scope/aliases/index.js @@ -4,7 +4,61 @@ */ import Controller from '@ember/controller'; +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 ScopesScopeAliasesIndexController extends Controller { // =services + @service can; + @service router; + // =actions + + /** + * Rollback changes on an alias. + * @param {AliasModel} alias + */ + @action + async cancel(alias) { + const { isNew } = alias; + alias.rollbackAttributes(); + if (isNew) await this.router.transitionTo('scopes.scope.aliases'); + } + + /** + * Save an alias in current scope. + * @param {AliasModel} alias + */ + @action + @loading + @notifyError(({ message }) => message) + @notifySuccess(({ isNew }) => + isNew ? 'notifications.create-success' : 'notifications.save-success', + ) + async save(alias) { + await alias.save(); + if (this.can.can('read model', alias)) { + await this.router.transitionTo('scopes.scope.aliases.alias', alias); + } else { + await this.router.transitionTo('scopes.scope.aliases'); + } + await this.router.refresh(); + } + + /** + * Delete an alias in current scope and redirect to index + * @param {AliasModel} alias + */ + @action + @loading + @confirm('questions.delete-confirm') + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.delete-success') + async delete(alias) { + await alias.destroyRecord(); + await this.router.replaceWith('scopes.scope.aliases'); + await this.router.refresh(); + } } diff --git a/ui/admin/app/templates/scopes/scope/aliases/index.hbs b/ui/admin/app/templates/scopes/scope/aliases/index.hbs index a92d14988..3ecaea57c 100644 --- a/ui/admin/app/templates/scopes/scope/aliases/index.hbs +++ b/ui/admin/app/templates/scopes/scope/aliases/index.hbs @@ -12,7 +12,7 @@ {{t 'resources.alias.title'}} {{! TODO: Add doc link for alias }} - {{! TODO: Add desc for alias }} +

{{t 'resources.alias.description'}}

{{#if (can 'create model' this.scope collection='aliases')}} @@ -24,4 +24,36 @@ {{/if}} {{/if}} + + + + + {{#if (can 'list model' this.scope collection='aliases')}} + {{t 'resources.alias.messages.none.description'}} + {{else if (can 'create model' this.scope collection='aliases')}} + {{t + 'descriptions.create-but-not-list' + resource=(t 'resources.alias.title_plural') + }} + {{else}} + {{t + 'descriptions.neither-list-nor-create' + resource=(t 'resources.alias.title_plural') + }} + {{/if}} + + {{#if (can 'create model' this.scope collection='aliases')}} + + {{/if}} + + + \ No newline at end of file diff --git a/ui/admin/app/templates/scopes/scope/aliases/new.hbs b/ui/admin/app/templates/scopes/scope/aliases/new.hbs index 692b40c16..47f0299c4 100644 --- a/ui/admin/app/templates/scopes/scope/aliases/new.hbs +++ b/ui/admin/app/templates/scopes/scope/aliases/new.hbs @@ -23,5 +23,11 @@ {{! TODO: Add desc for alias }} - + + + \ No newline at end of file diff --git a/ui/admin/tests/acceptance/aliases/create-test.js b/ui/admin/tests/acceptance/aliases/create-test.js new file mode 100644 index 000000000..6bf6e80df --- /dev/null +++ b/ui/admin/tests/acceptance/aliases/create-test.js @@ -0,0 +1,149 @@ +/** + * 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 | aliases | create', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + let features; + let getAliasCount; + + const SAVE_BTN_SELECTOR = '[type="submit"]'; + const CANCEL_BTN_SELECTOR = '.rose-form-actions [type="button"]'; + const NAME_FIELD_SELECTOR = '[name="name"]'; + const DESTINATION_ID_SELECTOR = '[name="destination_id"]'; + const HOST_ID_SELECTOR = '[name="authorize_session_arguments"]'; + + 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, + }, + }; + + const urls = { + globalScope: null, + aliases: null, + newAlias: null, + }; + + hooks.beforeEach(function () { + instances.scopes.global = this.server.create('scope', { id: 'global' }); + urls.globalScope = `/scopes/global`; + urls.aliases = `${urls.globalScope}/aliases`; + urls.newAlias = `${urls.aliases}/new`; + getAliasCount = () => this.server.schema.aliases.all().models.length; + features = this.owner.lookup('service:features'); + features.enable('ssh-session-recording'); + authenticateSession({}); + }); + + test('users can create a new alias with host and target info', async function (assert) { + const aliasCount = getAliasCount(); + await visit(urls.newAlias); + + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await fillIn(DESTINATION_ID_SELECTOR, 'tcp_123'); + await fillIn(HOST_ID_SELECTOR, 'h_123'); + await click(SAVE_BTN_SELECTOR); + const alias = this.server.schema.aliases.findBy({ + name: NAME_FIELD_TEXT, + }); + + assert.strictEqual(alias.name, NAME_FIELD_TEXT); + assert.strictEqual(getAliasCount(), aliasCount + 1); + }); + + test('users can create a new alias without host or target info', async function (assert) { + const aliasCount = getAliasCount(); + await visit(urls.newAlias); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + + await click(SAVE_BTN_SELECTOR); + const alias = this.server.schema.aliases.findBy({ + name: NAME_FIELD_TEXT, + }); + + assert.strictEqual(alias.name, NAME_FIELD_TEXT); + assert.strictEqual(getAliasCount(), aliasCount + 1); + }); + + test('user can cancel new alias creation', async function (assert) { + const aliasCount = getAliasCount(); + await visit(urls.newAlias); + await fillIn(NAME_FIELD_SELECTOR, NAME_FIELD_TEXT); + await click(CANCEL_BTN_SELECTOR); + + assert.strictEqual(currentURL(), urls.aliases); + assert.strictEqual(getAliasCount(), aliasCount); + }); + + test('saving a new alias with invalid fields displays error messages', async function (assert) { + this.server.post('/aliases', () => { + 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.newAlias); + 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('can navigate to new aliases route with proper authorization', async function (assert) { + await visit(urls.aliases); + + assert.ok( + instances.scopes.global.authorized_collection_actions.aliases.includes( + 'create', + ), + ); + + assert.dom(`[href="${urls.newAlias}"]`).isVisible(); + }); + + test('users cannot directly navigate to new alias route without proper authorization', async function (assert) { + instances.scopes.global.authorized_collection_actions['aliases'] = + instances.scopes.global.authorized_collection_actions['aliases'].filter( + (item) => item !== 'create', + ); + + await visit(urls.newAlias); + + assert.false( + instances.scopes.global.authorized_collection_actions['aliases'].includes( + 'create', + ), + ); + assert.strictEqual(currentURL(), urls.aliases); + }); +});