diff --git a/ui/app/adapters/kmip/role.js b/ui/app/adapters/kmip/role.js index 9779ad67a564..693ae6327e11 100644 --- a/ui/app/adapters/kmip/role.js +++ b/ui/app/adapters/kmip/role.js @@ -6,10 +6,11 @@ import BaseAdapter from './base'; import { decamelize } from '@ember/string'; import { getProperties } from '@ember/object'; +import { nonOperationFields } from 'vault/utils/kmip-role-fields'; export default BaseAdapter.extend({ createRecord(store, type, snapshot) { - const name = snapshot.id || snapshot.attr('name'); + const name = snapshot.id || snapshot.record.role; const url = this._url( type.modelName, { @@ -18,10 +19,11 @@ export default BaseAdapter.extend({ }, name ); - return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then(() => { + const data = this.serialize(snapshot); + return this.ajax(url, 'POST', { data }).then(() => { return { id: name, - name, + role: name, backend: snapshot.record.backend, scope: snapshot.record.scope, }; @@ -29,7 +31,8 @@ export default BaseAdapter.extend({ }, deleteRecord(store, type, snapshot) { - const name = snapshot.id || snapshot.attr('name'); + // records must always have IDs + const name = snapshot.id; const url = this._url( type.modelName, { @@ -41,35 +44,35 @@ export default BaseAdapter.extend({ return this.ajax(url, 'DELETE'); }, + updateRecord() { + return this.createRecord(...arguments); + }, + serialize(snapshot) { // the endpoint here won't allow sending `operation_all` and `operation_none` at the same time or with // other operation_ values, so we manually check for them and send an abbreviated object const json = snapshot.serialize(); - const keys = snapshot.record.nonOperationFields.map(decamelize); - const nonOperationFields = getProperties(json, keys); - for (const field in nonOperationFields) { - if (nonOperationFields[field] == null) { - delete nonOperationFields[field]; + const keys = nonOperationFields(snapshot.record.editableFields).map(decamelize); + const nonOp = getProperties(json, keys); + for (const field in nonOp) { + if (nonOp[field] == null) { + delete nonOp[field]; } } if (json.operation_all) { return { operation_all: true, - ...nonOperationFields, + ...nonOp, }; } if (json.operation_none) { return { operation_none: true, - ...nonOperationFields, + ...nonOp, }; } delete json.operation_none; delete json.operation_all; return json; }, - - updateRecord() { - return this.createRecord(...arguments); - }, }); diff --git a/ui/app/models/kmip/role.js b/ui/app/models/kmip/role.js index 8083acd7b331..520efcf1b517 100644 --- a/ui/app/models/kmip/role.js +++ b/ui/app/models/kmip/role.js @@ -4,52 +4,35 @@ */ import Model, { attr } from '@ember-data/model'; -import { computed } from '@ember/object'; -import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import apiPath from 'vault/utils/api-path'; import lazyCapabilities from 'vault/macros/lazy-capabilities'; +import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes'; +import { operationFields, operationFieldsWithoutSpecial, tlsFields } from 'vault/utils/kmip-role-fields'; import { removeManyFromArray } from 'vault/helpers/remove-from-array'; -const COMPUTEDS = { - operationFields: computed('newFields', function () { - return this.newFields.filter((key) => key.startsWith('operation')); - }), +@withExpandedAttributes() +export default class KmipRoleModel extends Model { + @attr({ readOnly: true }) backend; + @attr({ readOnly: true }) scope; - operationFieldsWithoutSpecial: computed('operationFields', function () { - return removeManyFromArray(this.operationFields, ['operationAll', 'operationNone']); - }), + get editableFields() { + return Object.keys(this.allByKey).filter((k) => !['backend', 'scope', 'role'].includes(k)); + } - tlsFields: computed(function () { - return ['tlsClientKeyBits', 'tlsClientKeyType', 'tlsClientTtl']; - }), - - // For rendering on the create/edit pages - defaultFields: computed('newFields', 'operationFields', 'tlsFields', function () { - const excludeFields = ['role'].concat(this.operationFields, this.tlsFields); - return removeManyFromArray(this.newFields, excludeFields); - }), - - // For adapter/serializer - nonOperationFields: computed('newFields', 'operationFields', function () { - return removeManyFromArray(this.newFields, this.operationFields); - }), -}; - -export default Model.extend(COMPUTEDS, { - backend: attr({ readOnly: true }), - scope: attr({ readOnly: true }), - name: attr({ readOnly: true }), - - fieldGroups: computed('fields', 'defaultFields.length', 'tlsFields', function () { - const groups = [{ TLS: this.tlsFields }]; - if (this.defaultFields.length) { - groups.unshift({ default: this.defaultFields }); + get fieldGroups() { + const tls = tlsFields(); + const groups = [{ TLS: tls }]; + // op fields are shown in OperationFieldDisplay + const opFields = operationFields(this.editableFields); + // not op fields, tls fields, or role/backend/scope + const defaultFields = this.editableFields.filter((f) => ![...opFields, ...tls].includes(f)); + if (defaultFields.length) { + groups.unshift({ default: defaultFields }); } - const ret = fieldToAttrs(this, groups); - return ret; - }), + return this._expandGroups(groups); + } - operationFormFields: computed('operationFieldsWithoutSpecial', function () { + get operationFormFields() { const objects = [ 'operationCreate', 'operationActivate', @@ -62,7 +45,7 @@ export default Model.extend(COMPUTEDS, { const attributes = ['operationAddAttribute', 'operationGetAttributes']; const server = ['operationDiscoverVersions']; - const others = removeManyFromArray(this.operationFieldsWithoutSpecial, [ + const others = removeManyFromArray(operationFieldsWithoutSpecial(this.editableFields), [ ...objects, ...attributes, ...server, @@ -77,14 +60,8 @@ export default Model.extend(COMPUTEDS, { Other: others, }); } - return fieldToAttrs(this, groups); - }), - tlsFormFields: computed('tlsFields', function () { - return expandAttributeMeta(this, this.tlsFields); - }), - fields: computed('defaultFields', function () { - return expandAttributeMeta(this, this.defaultFields); - }), + return this._expandGroups(groups); + } - updatePath: lazyCapabilities(apiPath`${'backend'}/scope/${'scope'}/role/${'id'}`, 'backend', 'scope', 'id'), -}); + @lazyCapabilities(apiPath`${'backend'}/scope/${'scope'}/role/${'id'}`, 'backend', 'scope', 'id') updatePath; +} diff --git a/ui/app/styles/components/kmip-role-edit.scss b/ui/app/styles/components/kmip-role-edit.scss index a517c1a54313..f4fc884be41f 100644 --- a/ui/app/styles/components/kmip-role-edit.scss +++ b/ui/app/styles/components/kmip-role-edit.scss @@ -3,11 +3,14 @@ * SPDX-License-Identifier: BUSL-1.1 */ +.kmip-role-operations { + column-count: 2; +} .kmip-role-allowed-operations { @extend .box; flex: 1 1 auto; box-shadow: none; - padding: 0; + padding: $spacing-4 0; } .kmip-role-allowed-operations .field { margin-bottom: $spacing-4; diff --git a/ui/app/utils/kmip-role-fields.js b/ui/app/utils/kmip-role-fields.js new file mode 100644 index 000000000000..3503ad58f449 --- /dev/null +++ b/ui/app/utils/kmip-role-fields.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { removeManyFromArray } from 'vault/helpers/remove-from-array'; + +export const operationFields = (fieldNames) => { + if (!Array.isArray(fieldNames)) { + throw new Error('fieldNames must be an array'); + } + return fieldNames.filter((key) => key.startsWith('operation')); +}; + +export const operationFieldsWithoutSpecial = (fieldNames) => { + const opFields = operationFields(fieldNames); + return removeManyFromArray(opFields, ['operationAll', 'operationNone']); +}; + +export const nonOperationFields = (fieldNames) => { + const opFields = operationFields(fieldNames); + return removeManyFromArray(fieldNames, opFields); +}; + +export const tlsFields = () => { + return ['tlsClientKeyBits', 'tlsClientKeyType', 'tlsClientTtl']; +}; diff --git a/ui/lib/kmip/addon/components/edit-form-kmip-role.js b/ui/lib/kmip/addon/components/edit-form-kmip-role.js deleted file mode 100644 index 43a230b80b19..000000000000 --- a/ui/lib/kmip/addon/components/edit-form-kmip-role.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import EditForm from 'core/components/edit-form'; -import { computed } from '@ember/object'; -import layout from '../templates/components/edit-form-kmip-role'; - -export default EditForm.extend({ - layout, - model: null, - - cancelLink: computed('cancelLinkParams.[]', function () { - if (!Array.isArray(this.cancelLinkParams) || !this.cancelLinkParams.length) return; - const [route, ...models] = this.cancelLinkParams; - return { route, models }; - }), - - init() { - this._super(...arguments); - - if (this.model.isNew) { - this.model.operationAll = true; - } - }, - - actions: { - toggleOperationSpecial(checked) { - this.model.operationNone = !checked; - this.model.operationAll = checked; - }, - - // when operationAll is true, we want all of the items - // to appear checked, but we don't want to override what items - // a user has selected - so this action creates an object that we - // pass to the FormField component as the model instead of the real model - placeholderOrModel(isOperationAll, attr) { - return isOperationAll ? { [attr.name]: true } : this.model; - }, - - preSave(model) { - // if we have operationAll or operationNone, we want to clear - // out the others so that display shows the right data - if (model.operationAll || model.operationNone) { - model.operationFieldsWithoutSpecial.forEach((field) => model.set(field, null)); - } - // set operationNone if user unchecks 'operationAll' instead of toggling the 'operationNone' input - // doing here instead of on the 'operationNone' input because a user might deselect all, then reselect some options - // and immediately setting operationNone will hide all of the checkboxes in the UI - this.model.operationNone = - model.operationFieldsWithoutSpecial.every((attr) => !model[attr]) && !this.model.operationAll; - }, - }, -}); diff --git a/ui/lib/kmip/addon/components/kmip/role-form.hbs b/ui/lib/kmip/addon/components/kmip/role-form.hbs new file mode 100644 index 000000000000..d10fb57add42 --- /dev/null +++ b/ui/lib/kmip/addon/components/kmip/role-form.hbs @@ -0,0 +1,82 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
\ No newline at end of file diff --git a/ui/lib/kmip/addon/components/kmip/role-form.js b/ui/lib/kmip/addon/components/kmip/role-form.js new file mode 100644 index 000000000000..496dde4106a0 --- /dev/null +++ b/ui/lib/kmip/addon/components/kmip/role-form.js @@ -0,0 +1,107 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import AdapterError from '@ember-data/adapter/error'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { task } from 'ember-concurrency'; +import { removeManyFromArray } from 'vault/helpers/remove-from-array'; +import { operationFieldsWithoutSpecial, tlsFields } from 'vault/utils/kmip-role-fields'; + +export default class KmipRoleFormComponent extends Component { + @service flashMessages; + @service store; + + // Actual attribute fields + get tlsFormFields() { + return tlsFields().map((attr) => this.args.model.allByKey[attr]); + } + get operationFormGroups() { + const objects = [ + 'operationCreate', + 'operationActivate', + 'operationGet', + 'operationLocate', + 'operationRekey', + 'operationRevoke', + 'operationDestroy', + ]; + const attributes = ['operationAddAttribute', 'operationGetAttributes']; + const server = ['operationDiscoverVersions']; + const others = removeManyFromArray(operationFieldsWithoutSpecial(this.args.model.editableFields), [ + ...objects, + ...attributes, + ...server, + ]); + const groups = [ + { name: 'Managed Cryptographic Objects', fields: objects }, + { name: 'Object Attributes', fields: attributes }, + { name: 'Server', fields: server }, + ]; + if (others.length) { + groups.push({ + name: 'Other', + fields: others, + }); + } + // expand field names to attributes + return groups.map((group) => ({ + ...group, + fields: group.fields.map((attr) => this.args.model.allByKey[attr]), + })); + } + + placeholderOrModel = (model, attrName) => { + return model.operationAll ? { [attrName]: true } : model; + }; + + preSave() { + const opFieldsWithoutSpecial = operationFieldsWithoutSpecial(this.args.model.editableFields); + // if we have operationAll or operationNone, we want to clear + // out the others so that display shows the right data + if (this.args.model.operationAll || this.args.model.operationNone) { + opFieldsWithoutSpecial.forEach((field) => (this.args.model[field] = null)); + } + // set operationNone if user unchecks 'operationAll' instead of toggling the 'operationNone' input + // doing here instead of on the 'operationNone' input because a user might deselect all, then reselect some options + // and immediately setting operationNone will hide all of the checkboxes in the UI + this.args.model.operationNone = + opFieldsWithoutSpecial.every((attr) => this.args.model[attr] !== true) && !this.args.model.operationAll; + return this.args.model; + } + + @action toggleOperationSpecial(evt) { + const { checked } = evt.target; + this.args.model.operationNone = !checked; + this.args.model.operationAll = checked; + } + + save = task(async (evt) => { + evt.preventDefault(); + const model = this.preSave(); + try { + await model.save(); + this.flashMessages.success(`Saved role ${model.role}`); + } catch (err) { + // err will display via model state + // AdapterErrors are handled by the error-message component + if (err instanceof AdapterError === false) { + throw err; + } + return; + } + this.args.onSave(); + }); + + willDestroy() { + // components are torn down after store is unloaded and will cause an error if attempt to unload record + const noTeardown = this.store && !this.store.isDestroying; + if (noTeardown && this.args?.model?.isDirty) { + this.args.model.rollbackAttributes(); + } + super.willDestroy(); + } +} diff --git a/ui/lib/kmip/addon/routes/scope/roles/create.js b/ui/lib/kmip/addon/routes/scope/roles/create.js index 82984cddcb63..7070b4c27408 100644 --- a/ui/lib/kmip/addon/routes/scope/roles/create.js +++ b/ui/lib/kmip/addon/routes/scope/roles/create.js @@ -21,6 +21,7 @@ export default Route.extend({ const model = this.store.createRecord('kmip/role', { backend: this.secretMountPath.currentPath, scope: this.scope(), + operationAll: true, }); return model; }, diff --git a/ui/lib/kmip/addon/templates/components/edit-form-kmip-role.hbs b/ui/lib/kmip/addon/templates/components/edit-form-kmip-role.hbs deleted file mode 100644 index 3aea47a1d8df..000000000000 --- a/ui/lib/kmip/addon/templates/components/edit-form-kmip-role.hbs +++ /dev/null @@ -1,107 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - - \ No newline at end of file diff --git a/ui/lib/kmip/addon/templates/role/edit.hbs b/ui/lib/kmip/addon/templates/role/edit.hbs index ab2003d85783..482083290567 100644 --- a/ui/lib/kmip/addon/templates/role/edit.hbs +++ b/ui/lib/kmip/addon/templates/role/edit.hbs @@ -13,8 +13,9 @@ -