diff --git a/changelog/17749.txt b/changelog/17749.txt new file mode 100644 index 000000000..54c88a800 --- /dev/null +++ b/changelog/17749.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: Add inline policy creation when creating an identity entity or group +``` diff --git a/ui/app/components/modal-form/FYI.md b/ui/app/components/modal-form/FYI.md new file mode 100644 index 000000000..35b65b1b4 --- /dev/null +++ b/ui/app/components/modal-form/FYI.md @@ -0,0 +1,3 @@ +These templates parent form components that render within a modal to create inline items via SearchSelectWithModal. +The modal opens when a user searches for an item that doesn't exist and clicks 'No results found for "${term}". Click here to create it.' +The templates are rendered using the {{component}} helper in 'search-select-with-modal.hbs' and are responsible for creating the model passed to the form. diff --git a/ui/app/components/modal-form/oidc-assignment-template.hbs b/ui/app/components/modal-form/oidc-assignment-template.hbs new file mode 100644 index 000000000..8af71a03f --- /dev/null +++ b/ui/app/components/modal-form/oidc-assignment-template.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/app/components/modal-form/oidc-assignment-template.js b/ui/app/components/modal-form/oidc-assignment-template.js new file mode 100644 index 000000000..f99bbbd62 --- /dev/null +++ b/ui/app/components/modal-form/oidc-assignment-template.js @@ -0,0 +1,36 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module ModalForm::OidcAssignmentTemplate + * ModalForm::OidcAssignmentTemplate components render within a modal and create a model using the input from the search select. The model is passed to the oidc/assignment-form. + * + * @example + * + * ``` + * @callback onCancel - callback triggered when cancel button is clicked + * @callback onSave - callback triggered when save button is clicked + * @param {string} nameInput - the name of the newly created assignment + */ + +export default class OidcAssignmentTemplate extends Component { + @service store; + @tracked assignment = null; // model record passed to oidc/assignment-form + + constructor() { + super(...arguments); + this.assignment = this.store.createRecord('oidc/assignment', { name: this.args.nameInput }); + } + + @action onSave(assignmentModel) { + this.args.onSave(assignmentModel); + // Reset component assignment for next use + this.assignment = null; + } +} diff --git a/ui/app/components/modal-form/policy-template.hbs b/ui/app/components/modal-form/policy-template.hbs new file mode 100644 index 000000000..7c752a5ab --- /dev/null +++ b/ui/app/components/modal-form/policy-template.hbs @@ -0,0 +1,77 @@ +{{#if this.policy.policyType}} + +{{/if}} +{{#if this.showExamplePolicy}} +
+ {{#if (eq this.policy.policyType "acl")}} +

+ ACL Policies are written in Hashicorp Configuration Language ( + HCL + ) or JSON and describe which paths in Vault a user or machine is allowed to access. Here is an example policy: +

+ {{else}} +

+ Role Governing Policies (RGPs) are tied to client tokens or identities which is similar to + ACL policies. + They use + Sentinel + as a language framework to enable fine-grained policy decisions. +

+

+ Here is an example policy that uses RGP to restrict access to the + admin + policy such that a user named James or has the + Team Lead + role can manage the + admin + policy: +

+ {{/if}} +
+ +{{else}} + + + + {{/if}} +
+ {{#if @model.isNew}} + + + +
+
+ + +
+
+
+ {{#if this.showFileUpload}} + + {{else}} + + {{/if}} + {{else}} + {{! EDITING - no file upload toggle}} + + {{/if}} +
+ {{#each @model.additionalAttrs as |attr|}} + + {{/each}} + +
+

+ More information about + {{uppercase @model.policyType}} + policies can be found + + here. + +

+
+
+
+ + +
+
+ \ No newline at end of file diff --git a/ui/app/components/policy-form.js b/ui/app/components/policy-form.js new file mode 100644 index 000000000..6b367cea1 --- /dev/null +++ b/ui/app/components/policy-form.js @@ -0,0 +1,69 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import trimRight from 'vault/utils/trim-right'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module PolicyForm + * PolicyForm components are the forms to create and edit all types of policies. This is only the form, not the outlying layout, and expects that the form model is passed from the parent. + * + * @example + * + * ``` + * @callback onCancel - callback triggered when cancel button is clicked + * @callback onSave - callback triggered when save button is clicked. Passes saved model + * @param {object} model - ember data model from createRecord + */ + +export default class PolicyFormComponent extends Component { + @service flashMessages; + + @tracked errorBanner = ''; + @tracked file = null; + @tracked showFileUpload = false; + + @task + *save(event) { + event.preventDefault(); + try { + const { name, policyType, isNew } = this.args.model; + yield this.args.model.save(); + this.flashMessages.success( + `${policyType.toUpperCase()} policy "${name}" was successfully ${isNew ? 'created' : 'updated'}.` + ); + this.args.onSave(this.args.model); + } catch (error) { + const message = error.errors ? error.errors.join('. ') : error.message; + this.errorBanner = message; + } + } + + @action + setModelName({ target }) { + this.args.model.name = target.value.toLowerCase(); + } + + @action + setPolicyFromFile(index, fileInfo) { + const { value, fileName } = fileInfo; + this.args.model.policy = value; + if (!this.args.model.name) { + const trimmedFileName = trimRight(fileName, ['.json', '.txt', '.hcl', '.policy']); + this.args.model.name = trimmedFileName.toLowerCase(); + } + this.showFileUpload = false; + } + + @action + cancel() { + const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes'; + this.args.model[method](); + this.args.onCancel(); + } +} diff --git a/ui/app/components/ui-wizard.js b/ui/app/components/ui-wizard.js index 7a7341f05..009654952 100644 --- a/ui/app/components/ui-wizard.js +++ b/ui/app/components/ui-wizard.js @@ -7,6 +7,7 @@ export default Component.extend({ classNames: ['ui-wizard-container'], wizard: service(), auth: service(), + router: service(), shouldRender: or('auth.currentToken', 'wizard.showWhenUnauthenticated'), currentState: alias('wizard.currentState'), @@ -16,6 +17,7 @@ export default Component.extend({ componentState: alias('wizard.componentState'), nextFeature: alias('wizard.nextFeature'), nextStep: alias('wizard.nextStep'), + currentRouteName: alias('router.currentRouteName'), actions: { dismissWizard() { diff --git a/ui/app/controllers/vault/cluster/policies/create.js b/ui/app/controllers/vault/cluster/policies/create.js deleted file mode 100644 index 69e22f68f..000000000 --- a/ui/app/controllers/vault/cluster/policies/create.js +++ /dev/null @@ -1,20 +0,0 @@ -import Controller from '@ember/controller'; -import trimRight from 'vault/utils/trim-right'; -import PolicyEditController from 'vault/mixins/policy-edit-controller'; - -export default Controller.extend(PolicyEditController, { - showFileUpload: false, - file: null, - actions: { - setPolicyFromFile(index, fileInfo) { - const { value, fileName } = fileInfo; - const model = this.model; - model.set('policy', value); - if (!model.get('name')) { - const trimmedFileName = trimRight(fileName, ['.json', '.txt', '.hcl', '.policy']); - model.set('name', trimmedFileName); - } - this.set('showFileUpload', false); - }, - }, -}); diff --git a/ui/app/controllers/vault/cluster/policy/edit.js b/ui/app/controllers/vault/cluster/policy/edit.js index 8a14b3c90..927e70ea4 100644 --- a/ui/app/controllers/vault/cluster/policy/edit.js +++ b/ui/app/controllers/vault/cluster/policy/edit.js @@ -1,4 +1,27 @@ import Controller from '@ember/controller'; -import PolicyEditController from 'vault/mixins/policy-edit-controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; -export default Controller.extend(PolicyEditController); +export default class PolicyEditController extends Controller { + @service router; + @service flashMessages; + @service wizard; + + @action + async deletePolicy() { + const { policyType, name } = this.model; + try { + await this.model.destroyRecord(); + this.flashMessages.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`); + this.router.transitionTo('vault.cluster.policies', policyType); + if (this.wizard.featureState === 'delete') { + this.wizard.transitionFeatureMachine('delete', 'CONTINUE', policyType); + } + } catch (error) { + this.model.rollbackAttributes(); + const errors = error.errors ? error.errors.join('. ') : error.message; + const message = `There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.`; + this.flashMessages.danger(message); + } + } +} diff --git a/ui/app/mixins/policy-edit-controller.js b/ui/app/mixins/policy-edit-controller.js deleted file mode 100644 index e56d6a318..000000000 --- a/ui/app/mixins/policy-edit-controller.js +++ /dev/null @@ -1,49 +0,0 @@ -import { inject as service } from '@ember/service'; -import Mixin from '@ember/object/mixin'; - -export default Mixin.create({ - flashMessages: service(), - wizard: service(), - actions: { - deletePolicy(model) { - const policyType = model.get('policyType'); - const name = model.get('name'); - const flash = this.flashMessages; - model - .destroyRecord() - .then(() => { - flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully deleted.`); - return this.transitionToRoute('vault.cluster.policies', policyType); - }) - .catch((e) => { - const errors = e.errors ? e.errors.join('') : e.message; - flash.danger( - `There was an error deleting the ${policyType.toUpperCase()} policy "${name}": ${errors}.` - ); - }); - }, - - savePolicy(model) { - const flash = this.flashMessages; - const policyType = model.get('policyType'); - const name = model.get('name'); - model - .save() - .then((m) => { - flash.success(`${policyType.toUpperCase()} policy "${name}" was successfully saved.`); - if (this.wizard.featureState === 'create') { - this.wizard.transitionFeatureMachine('create', 'CONTINUE', policyType); - } - return this.transitionToRoute('vault.cluster.policy.show', m.get('policyType'), m.get('name')); - }) - .catch(() => { - // swallow error -- model.errors set by Adapter - return; - }); - }, - - setModelName(model, e) { - model.set('name', e.target.value.toLowerCase()); - }, - }, -}); diff --git a/ui/app/models/identity/entity.js b/ui/app/models/identity/entity.js index 0a73af737..d56fde95e 100644 --- a/ui/app/models/identity/entity.js +++ b/ui/app/models/identity/entity.js @@ -4,6 +4,7 @@ import { alias } from '@ember/object/computed'; import IdentityModel from './_base'; import apiPath from 'vault/utils/api-path'; import attachCapabilities from 'vault/lib/attach-capabilities'; +import lazyCapabilities from 'vault/macros/lazy-capabilities'; const Model = IdentityModel.extend({ formFields: computed(function () { @@ -20,11 +21,8 @@ const Model = IdentityModel.extend({ editType: 'kv', }), policies: attr({ - label: 'Policies', - editType: 'searchSelect', + editType: 'yield', isSectionHeader: true, - fallbackComponent: 'string-list', - models: ['policy/acl', 'policy/rgp'], }), creationTime: attr('string', { readOnly: true, @@ -46,6 +44,8 @@ const Model = IdentityModel.extend({ canEdit: alias('updatePath.canUpdate'), canRead: alias('updatePath.canRead'), canAddAlias: alias('aliasPath.canCreate'), + policyPath: lazyCapabilities(apiPath`sys/policies`), + canCreatePolicies: alias('policyPath.canCreate'), }); export default attachCapabilities(Model, { diff --git a/ui/app/models/identity/group.js b/ui/app/models/identity/group.js index 9f51db140..42da7de7f 100644 --- a/ui/app/models/identity/group.js +++ b/ui/app/models/identity/group.js @@ -34,11 +34,8 @@ export default IdentityModel.extend({ editType: 'kv', }), policies: attr({ - label: 'Policies', - editType: 'searchSelect', + editType: 'yield', isSectionHeader: true, - fallbackComponent: 'string-list', - models: ['policy/acl', 'policy/rgp'], }), memberGroupIds: attr({ label: 'Member Group IDs', @@ -73,7 +70,8 @@ export default IdentityModel.extend({ return numEntities + numGroups > 0; } ), - + policyPath: lazyCapabilities(apiPath`sys/policies`), + canCreatePolicies: alias('policyPath.canCreate'), alias: belongsTo('identity/group-alias', { async: false, readOnly: true }), updatePath: identityCapabilities(), canDelete: alias('updatePath.canDelete'), diff --git a/ui/app/routes/vault/cluster/policy/edit.js b/ui/app/routes/vault/cluster/policy/edit.js index c0743fc75..4b22d1d2f 100644 --- a/ui/app/routes/vault/cluster/policy/edit.js +++ b/ui/app/routes/vault/cluster/policy/edit.js @@ -1,4 +1,13 @@ import UnsavedModelRoute from 'vault/mixins/unsaved-model-route'; import ShowRoute from './show'; +import { inject as service } from '@ember/service'; -export default ShowRoute.extend(UnsavedModelRoute); +export default ShowRoute.extend(UnsavedModelRoute, { + wizard: service(), + + activate() { + if (this.wizard.featureState === 'details') { + this.wizard.transitionFeatureMachine('details', 'CONTINUE', this.policyType()); + } + }, +}); diff --git a/ui/app/routes/vault/cluster/policy/show.js b/ui/app/routes/vault/cluster/policy/show.js index e6d1e9ac2..89c27a2b2 100644 --- a/ui/app/routes/vault/cluster/policy/show.js +++ b/ui/app/routes/vault/cluster/policy/show.js @@ -5,6 +5,13 @@ import { inject as service } from '@ember/service'; export default Route.extend(UnloadModelRoute, { store: service(), + wizard: service(), + + activate() { + if (this.wizard.featureState === 'create') { + this.wizard.transitionFeatureMachine('create', 'CONTINUE', this.policyType()); + } + }, beforeModel() { const params = this.paramsFor(this.routeName); diff --git a/ui/app/styles/components/modal.scss b/ui/app/styles/components/modal.scss index 0b8aeb061..61b8ca436 100644 --- a/ui/app/styles/components/modal.scss +++ b/ui/app/styles/components/modal.scss @@ -7,6 +7,7 @@ border: 1px solid $grey-light; max-height: calc(100vh - 70px); margin-top: 60px; + min-width: calc(100vw * 0.3); &-head { border-radius: 0; diff --git a/ui/app/templates/components/identity/edit-form.hbs b/ui/app/templates/components/identity/edit-form.hbs index 869fc3cd7..469e8251d 100644 --- a/ui/app/templates/components/identity/edit-form.hbs +++ b/ui/app/templates/components/identity/edit-form.hbs @@ -24,7 +24,34 @@ /> {{/if}} {{#each this.model.fields as |attr|}} - + +
+ {{#if this.model.canCreatePolicies}} + + {{else}} + + {{/if}} +
+
{{/each}} diff --git a/ui/app/templates/components/oidc/assignment-form.hbs b/ui/app/templates/components/oidc/assignment-form.hbs index 48ced294b..6f4d2734c 100644 --- a/ui/app/templates/components/oidc/assignment-form.hbs +++ b/ui/app/templates/components/oidc/assignment-form.hbs @@ -1,32 +1,3 @@ -{{#unless @isInline}} - - - - - -

- {{if @model.isNew "Create" "Edit"}} - assignment -

-
-
-{{/unless}}
diff --git a/ui/app/templates/components/oidc/client-form.hbs b/ui/app/templates/components/oidc/client-form.hbs index 893ff199a..2023a5eb3 100644 --- a/ui/app/templates/components/oidc/client-form.hbs +++ b/ui/app/templates/components/oidc/client-form.hbs @@ -60,12 +60,12 @@ @id="assignments" @label="Assignment name" @subText="Search for an existing assignment, or type a new name to create it." - @model="oidc/assignment" + @models={{array "oidc/assignment"}} @inputValue={{this.modelAssignments}} @onChange={{this.handleAssignmentSelection}} @excludeOptions={{array "allow_all"}} @fallbackComponent="string-list" - @modalFormComponent="oidc/assignment-form" + @modalFormTemplate="modal-form/oidc-assignment-template" @modalSubtext="Use assignment to specify which Vault entities and groups are allowed to authenticate." /> {{/if}} diff --git a/ui/app/templates/components/ui-wizard.hbs b/ui/app/templates/components/ui-wizard.hbs index f846cf0c9..90d8b3183 100644 --- a/ui/app/templates/components/ui-wizard.hbs +++ b/ui/app/templates/components/ui-wizard.hbs @@ -16,6 +16,7 @@ onRepeat=(action "repeatStep") onReset=(action "resetFeature") onAdvance=(action "advanceFeature") + currentRouteName=this.currentRouteName }} {{/component}} {{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/wizard/policies-delete.hbs b/ui/app/templates/components/wizard/policies-delete.hbs index d0430edb8..3bc9183da 100644 --- a/ui/app/templates/components/wizard/policies-delete.hbs +++ b/ui/app/templates/components/wizard/policies-delete.hbs @@ -7,7 +7,11 @@ @instructions='Click on "Delete" to remove the policy that you created.' >

- You can delete your test policy by clicking the "..." icon to the right of the policy name. + {{#if (eq @currentRouteName "vault.cluster.policies.index")}} + You can delete your test policy by clicking the "..." icon to the right of the policy name. + {{else}} + You can delete your test policy by clicking "Delete" in the toolbar. + {{/if}}

\ No newline at end of file diff --git a/ui/app/templates/components/wizard/policies-details.hbs b/ui/app/templates/components/wizard/policies-details.hbs index a2cdab12e..d6eb2b15a 100644 --- a/ui/app/templates/components/wizard/policies-details.hbs +++ b/ui/app/templates/components/wizard/policies-details.hbs @@ -7,7 +7,7 @@ @instructions='Click on "ACL Policies" in the sidebar to go back to the list of policies.' >

- Good job! Here you can see your new policy. If you'd like to edit it, you'd just click the "Edit" toggle. + Good job! Here you can see your new policy. If you'd like to edit it, you'd just click "Edit policy" in the toolbar.

\ No newline at end of file diff --git a/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs index 1c302ffa9..5ffb72175 100644 --- a/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs +++ b/ui/app/templates/vault/cluster/access/oidc/assignments/assignment/edit.hbs @@ -1,3 +1,22 @@ + + + + + +

+ Edit assignment +

+
+
+ + + + +

+ Create assignment +

+
+ - -
- - -
- -
- -
-
-
- - - -
-
- - -
-
-
- {{#if this.showFileUpload}} - - {{else}} - - {{/if}} -
- {{#each this.model.additionalAttrs as |attr|}} - - {{/each}} -
-
-
- -
-
- - Cancel - -
-
- \ No newline at end of file + \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/policy/edit.hbs b/ui/app/templates/vault/cluster/policy/edit.hbs index 526b72dcc..bd057e4e9 100644 --- a/ui/app/templates/vault/cluster/policy/edit.hbs +++ b/ui/app/templates/vault/cluster/policy/edit.hbs @@ -28,7 +28,7 @@ Delete @@ -41,42 +41,9 @@ {{/if}} -
-
- - -
- -
-

- You can use Alt+Tab (Option+Tab on MacOS) in the code editor to skip to the next field -

-
-
- {{#each this.model.additionalAttrs as |attr|}} - - {{/each}} -
-
- {{#if this.capabilities.canUpdate}} -
- -
- {{/if}} -
- - Cancel - -
-
-
-
-
\ No newline at end of file + + \ No newline at end of file diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index fd5228b79..151fafe28 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -1,23 +1,13 @@ {{! template-lint-configure simple-unless "warn" }}
- {{#unless - (or - (eq @attr.type "boolean") - (includes - @attr.options.editType - (array "boolean" "optionalText" "searchSelect" "mountAccessor" "kv" "file" "ttl" "stringArray" "json" "regex") - ) - ) - }} - {{#if (not (eq @attr.type "object"))}} - - {{/if}} + {{#unless this.hideLabel}} + {{/unless}} {{#if @attr.options.possibleValues}} {{#if (eq @attr.options.editType "radio")}} diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index 7ddf1e47d..5c2b5d47e 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -42,9 +42,21 @@ import { dasherize } from 'vault/helpers/dasherize'; */ export default class FormFieldComponent extends Component { + emptyData = '{\n}'; + shouldHideLabel = [ + 'boolean', + 'file', + 'json', + 'kv', + 'mountAccessor', + 'optionalText', + 'regex', + 'searchSelect', + 'stringArray', + 'ttl', + ]; @tracked showInput = false; @tracked file = { value: '' }; // used by the pgp-file component when an attr is editType of 'file' - emptyData = '{\n}'; constructor() { super(...arguments); @@ -54,6 +66,15 @@ export default class FormFieldComponent extends Component { this.showInput = !!modelValue; } + get hideLabel() { + const { type, options } = this.args.attr; + if (type === 'boolean' || type === 'object' || options?.isSectionHeader) { + return true; + } + // falsey values render a + return this.shouldHideLabel.includes(options?.editType); + } + get disabled() { return this.args.disabled || false; } diff --git a/ui/lib/core/addon/components/search-select-with-modal.hbs b/ui/lib/core/addon/components/search-select-with-modal.hbs index 37a7f4401..efe6247bd 100644 --- a/ui/lib/core/addon/components/search-select-with-modal.hbs +++ b/ui/lib/core/addon/components/search-select-with-modal.hbs @@ -1,4 +1,10 @@ -
+
{{#if this.shouldUseFallback}} {{component @fallbackComponent @@ -12,7 +18,7 @@ }} {{else}} {{#if @label}} -