diff --git a/changelog/29754.txt b/changelog/29754.txt new file mode 100644 index 0000000000..ad576f4390 --- /dev/null +++ b/changelog/29754.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui/database: Glimmerizing and adding validations to role create +``` \ No newline at end of file diff --git a/ui/app/components/database-role-edit.js b/ui/app/components/database-role-edit.js index 36ace844ec..aeb243e195 100644 --- a/ui/app/components/database-role-edit.js +++ b/ui/app/components/database-role-edit.js @@ -2,19 +2,39 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ - import Component from '@glimmer/component'; import { service } from '@ember/service'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; +import { waitFor } from '@ember/test-waiters'; +import { task } from 'ember-concurrency'; +import errorMessage from 'vault/utils/error-message'; +/** + * @module DatabaseRoleEdit component is used to configure a database role. + * See secret-edit-layout which uses options for backend to determine which component to render. + * @example + * + * + * @param {object} model - The database role model. + * @param {string} tab - The tab to render. + * @param {string} mode - The mode to render. Either 'create' or 'edit'. + * @param {string} [initialKey] - The initial key to set for the database role. + */ const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; - export default class DatabaseRoleEdit extends Component { @service router; @service flashMessages; @service store; + @tracked modelValidations; + @tracked invalidFormAlert; + @tracked errorMessage = ''; constructor() { super(...arguments); @@ -23,19 +43,30 @@ export default class DatabaseRoleEdit extends Component { } } - @tracked loading = false; + isValid() { + const { isValid, state } = this.args.model.validate(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = 'There was an error submitting this form.'; + return isValid; + } + + resetErrors() { + this.flashMessages.clearMessages(); + this.errorMessage = this.invalidFormAlert = ''; + this.modelValidations = null; + } get warningMessages() { const warnings = {}; + const { canCreateDynamic, canCreateStatic, type } = this.args.model; if ( - (this.args.model.type === 'dynamic' && this.args.model.canCreateDynamic === false) || - (this.args.model.type === 'static' && this.args.model.canCreateStatic === false) + (type === 'dynamic' && canCreateDynamic === false) || + (type === 'static' && canCreateStatic === false) ) { warnings.type = `You don't have permissions to create this type of role.`; } return warnings; } - get databaseType() { const backend = this.args.model?.backend; const dbs = this.args.model?.database || []; @@ -73,36 +104,29 @@ export default class DatabaseRoleEdit extends Component { }); } - @action - handleCreateEditRole(evt) { - evt.preventDefault(); - this.loading = true; - - const mode = this.args.mode; - const roleSecret = this.args.model; - const secretId = roleSecret.name; - if (mode === 'create') { - roleSecret.set('id', secretId); - const path = roleSecret.type === 'static' ? 'static-roles' : 'roles'; - roleSecret.set('path', path); - } - return roleSecret - .save() - .then(() => { - try { - this.router.transitionTo(SHOW_ROUTE, `role/${secretId}`); - } catch (e) { - console.debug(e); // eslint-disable-line - } - }) - .catch((e) => { - const errorMessage = e.errors?.join('. ') || e.message; + handleCreateEditRole = task( + waitFor(async (evt) => { + evt.preventDefault(); + this.resetErrors(); + const { mode, model } = this.args; + if (!this.isValid()) return; + if (mode === 'create') { + model.id = model.name; + const path = model.type === 'static' ? 'static-roles' : 'roles'; + model.path = path; + } + try { + await model.save(); + this.router.transitionTo(SHOW_ROUTE, `role/${model.name}`); + } catch (e) { + this.errorMessage = errorMessage(e); this.flashMessages.danger( - errorMessage || 'Could not save the role. Please check Vault logs for more information.' + this.errorMessage || 'Could not save the role. Please check Vault logs for more information.' ); - this.loading = false; - }); - } + } + }) + ); + @action rotateRoleCred(id) { const backend = this.args.model?.backend; diff --git a/ui/app/models/database/role.js b/ui/app/models/database/role.js index 4c75e99601..7dccc41edf 100644 --- a/ui/app/models/database/role.js +++ b/ui/app/models/database/role.js @@ -2,21 +2,31 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ - import Model, { attr } from '@ember-data/model'; -import { computed } from '@ember/object'; -import { alias } from '@ember/object/computed'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; -import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import { getRoleFields } from 'vault/utils/model-helpers/database-helpers'; - -export default Model.extend({ - idPrefix: 'role/', - backend: attr('string', { readOnly: true }), - name: attr('string', { - label: 'Role name', - }), - database: attr('array', { +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { withModelValidations } from 'vault/decorators/model-validations'; +const validations = { + database: [{ type: 'presence', message: 'Database is required.' }], + type: [{ type: 'presence', message: 'Type is required.' }], + username: [ + { + validator(model) { + const { type, username } = model; + if (!type || type === 'dynamic') return true; + if (username) return true; + }, + message: 'Username is required.', + }, + ], +}; +@withModelValidations(validations) +export default class RoleModel extends Model { + idPrefix = 'role/'; + @attr('string', { readOnly: true }) backend; + @attr('string', { label: 'Role name' }) name; + @attr('array', { label: 'Connection name', editType: 'searchSelect', fallbackComponent: 'string-list', @@ -24,81 +34,90 @@ export default Model.extend({ selectLimit: 1, onlyAllowExisting: true, subText: 'The database connection for which credentials will be generated.', - }), - type: attr('string', { + }) + database; + @attr('string', { label: 'Type of role', noDefault: true, possibleValues: ['static', 'dynamic'], - }), - default_ttl: attr({ + }) + type; + @attr({ editType: 'ttl', defaultValue: '1h', label: 'Generated credentials’s Time-to-Live (TTL)', helperTextDisabled: 'Vault will use a TTL of 1 hour.', defaultShown: 'Engine default', - }), - max_ttl: attr({ + }) + default_ttl; + @attr({ editType: 'ttl', defaultValue: '24h', label: 'Generated credentials’s maximum Time-to-Live (Max TTL)', helperTextDisabled: 'Vault will use a TTL of 24 hours.', defaultShown: 'Engine default', - }), - username: attr('string', { - subText: 'The database username that this Vault role corresponds to.', - }), - rotation_period: attr({ + }) + max_ttl; + @attr('string', { subText: 'The database username that this Vault role corresponds to.' }) username; + @attr({ editType: 'ttl', defaultValue: '24h', helperTextDisabled: 'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds. Default is 24 hours.', - helperTextEnabled: 'Vault will rotate password after', - }), - skip_import_rotation: attr({ + helperTextEnabled: 'Vault will rotate password after.', + }) + rotation_period; + @attr({ label: 'Skip initial rotation', editType: 'boolean', defaultValue: false, - subText: 'When unchecked, Vault automatically rotates the password upon creation', - }), - creation_statements: attr('array', { + subText: 'When unchecked, Vault automatically rotates the password upon creation.', + }) + skip_import_rotation; + @attr('array', { editType: 'stringArray', - }), - revocation_statements: attr('array', { + }) + creation_statements; + @attr('array', { editType: 'stringArray', defaultShown: 'Default', - }), - rotation_statements: attr('array', { + }) + revocation_statements; + @attr('array', { editType: 'stringArray', defaultShown: 'Default', - }), - rollback_statements: attr('array', { + }) + rotation_statements; + @attr('array', { editType: 'stringArray', defaultShown: 'Default', - }), - renew_statements: attr('array', { + }) + rollback_statements; + @attr('array', { editType: 'stringArray', defaultShown: 'Default', - }), - creation_statement: attr('string', { + }) + renew_statements; + @attr('string', { editType: 'json', allowReset: true, theme: 'hashi short', defaultShown: 'Default', - }), - revocation_statement: attr('string', { + }) + creation_statement; + @attr('string', { editType: 'json', allowReset: true, theme: 'hashi short', defaultShown: 'Default', - }), - + }) + revocation_statement; /* FIELD ATTRIBUTES */ get fieldAttrs() { // Main fields on edit/create form const fields = ['name', 'database', 'type']; return expandAttributeMeta(this, fields); - }, - + } get showFields() { let fields = ['name', 'database', 'type']; fields = fields.concat(getRoleFields(this.type)).concat(['creation_statements']); @@ -107,9 +126,8 @@ export default Model.extend({ fields = fields.concat(['revocation_statements']); } return expandAttributeMeta(this, fields); - }, - - roleSettingAttrs: computed(function () { + } + get roleSettingAttrs() { // logic for which get displayed is on DatabaseRoleSettingForm const allRoleSettingFields = [ 'default_ttl', @@ -126,25 +144,40 @@ export default Model.extend({ 'renew_statements', ]; return expandAttributeMeta(this, allRoleSettingFields); - }), - + } /* CAPABILITIES */ // only used for secretPath - path: attr('string', { readOnly: true }), + @attr('string', { readOnly: true }) path; + @lazyCapabilities(apiPath`${'backend'}/${'path'}/${'id'}`, 'backend', 'path', 'id') secretPath; + @lazyCapabilities(apiPath`${'backend'}/roles/+`, 'backend') dynamicPath; + @lazyCapabilities(apiPath`${'backend'}/static-roles/+`, 'backend') staticPath; + @lazyCapabilities(apiPath`${'backend'}/creds/${'id'}`, 'backend', 'id') credentialPath; + @lazyCapabilities(apiPath`${'backend'}/static-creds/${'id'}`, 'backend', 'id') staticCredentialPath; + @lazyCapabilities(apiPath`${'backend'}/config/${'database[0]'}`, 'backend', 'database') databasePath; + @lazyCapabilities(apiPath`${'backend'}/rotate-role/${'id'}`, 'backend', 'id') rotateRolePath; - secretPath: lazyCapabilities(apiPath`${'backend'}/${'path'}/${'id'}`, 'backend', 'path', 'id'), - canEditRole: alias('secretPath.canUpdate'), - canDelete: alias('secretPath.canDelete'), - dynamicPath: lazyCapabilities(apiPath`${'backend'}/roles/+`, 'backend'), - canCreateDynamic: alias('dynamicPath.canCreate'), - staticPath: lazyCapabilities(apiPath`${'backend'}/static-roles/+`, 'backend'), - canCreateStatic: alias('staticPath.canCreate'), - credentialPath: lazyCapabilities(apiPath`${'backend'}/creds/${'id'}`, 'backend', 'id'), - staticCredentialPath: lazyCapabilities(apiPath`${'backend'}/static-creds/${'id'}`, 'backend', 'id'), - canGenerateCredentials: alias('credentialPath.canRead'), - canGetCredentials: alias('staticCredentialPath.canRead'), - databasePath: lazyCapabilities(apiPath`${'backend'}/config/${'database[0]'}`, 'backend', 'database'), - canUpdateDb: alias('databasePath.canUpdate'), - rotateRolePath: lazyCapabilities(apiPath`${'backend'}/rotate-role/${'id'}`, 'backend', 'id'), - canRotateRoleCredentials: alias('rotateRolePath.canUpdate'), -}); + get canEditRole() { + return this.secretPath.get('canUpdate'); + } + get canDelete() { + return this.secretPath.get('canDelete'); + } + get canCreateDynamic() { + return this.dynamicPath.get('canCreate'); + } + get canCreateStatic() { + return this.staticPath.get('canCreate'); + } + get canGenerateCredentials() { + return this.credentialPath.get('canRead'); + } + get canGetCredentials() { + return this.staticCredentialPath.get('canRead'); + } + get canUpdateDb() { + return this.databasePath.get('canUpdate'); + } + get canRotateRoleCredentials() { + return this.rotateRolePath.get('canUpdate'); + } +} diff --git a/ui/app/templates/components/database-role-edit.hbs b/ui/app/templates/components/database-role-edit.hbs index 29ec32c376..ad5b4b422f 100644 --- a/ui/app/templates/components/database-role-edit.hbs +++ b/ui/app/templates/components/database-role-edit.hbs @@ -2,10 +2,9 @@ Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: BUSL-1.1 ~}} - - +

@@ -19,7 +18,6 @@

- {{#if (eq @mode "show")}} @@ -28,7 +26,7 @@ @buttonText="Delete role" class="toolbar-button" @buttonColor="secondary" - @onConfirmAction={{action "delete"}} + @onConfirmAction={{this.delete}} @confirmTitle="Delete role?" @confirmMessage="This role will be permanently deleted. You will need to recreate it to use it again." data-test-database-role-delete @@ -102,12 +100,13 @@ {{else}} {{! Edit or Create }}
-
+ + {{#each @model.fieldAttrs as |attr|}} {{#if (eq @mode "edit")}} {{else if (not-eq attr.options.readOnly true)}} - + {{! TODO: If database && !updateDB show warning }} {{#if (get this.warningMessages attr.name)}} @@ -117,44 +116,50 @@ {{/if}} {{/if}} {{/each}} - -
-
- - - - - {{#if (not (is-empty-value this.warningMessages))}} - - Warning - - You don't have permissions required to - {{if (eq @mode "create") "create" "update"}} - this role. See form for details. - - - {{/if}} -
+
+ + + + + {{#if this.invalidFormAlert}} + + {{/if}} + {{#if (not (is-empty-value this.warningMessages))}} + + Warning + + You don't have permissions required to + {{if (eq @mode "create") "create" "update"}} + this role. See form for details. + + + {{/if}}
diff --git a/ui/app/templates/components/database-role-setting-form.hbs b/ui/app/templates/components/database-role-setting-form.hbs index 00772421f8..64773a3f5e 100644 --- a/ui/app/templates/components/database-role-setting-form.hbs +++ b/ui/app/templates/components/database-role-setting-form.hbs @@ -11,7 +11,7 @@ {{#if (and (eq @mode "edit") (eq attr.name "username"))}} {{else}} - + {{/if}} {{/each}} diff --git a/ui/tests/integration/components/database-role-edit-test.js b/ui/tests/integration/components/database-role-edit-test.js index a9ce5aa926..1fbbb089f2 100644 --- a/ui/tests/integration/components/database-role-edit-test.js +++ b/ui/tests/integration/components/database-role-edit-test.js @@ -35,8 +35,25 @@ module('Integration | Component | database-role-edit', function (hooks) { name: 'my-dynamic-role', id: 'my-dynamic-role', }); + this.store.pushPayload('database-role', { + modelName: 'database/role', + database: ['my-mongodb-database'], + id: 'test-role', + type: 'static', + name: 'test-role', + }); this.modelStatic = this.store.peekRecord('database/role', 'my-static-role'); this.modelDynamic = this.store.peekRecord('database/role', 'my-dynamic-role'); + this.modelEmpty = this.store.peekRecord('database/role', 'test-role'); + }); + + test('it should display form errors when trying to create a role without required fields', async function (assert) { + this.server.post('/sys/capabilities-self', capabilitiesStub('database/static-creds/my-role', ['create'])); + + await render(hbs``); + await click('[data-test-secret-save]'); + + assert.dom('[data-test-inline-error-message]').exists('Inline form errors exist'); }); test('it should let user edit a static role when given update capability', async function (assert) {